Merge pull request #56 from AlexisNP/features/calendar-public-links
Features/calendar public links
This commit is contained in:
@@ -20,8 +20,7 @@ const { setActiveCalendar } = useCalendar()
|
||||
setActiveCalendar(props.calendarData, props.categories)
|
||||
|
||||
const { currentConfig, jumpToDate, selectedDate } = useCalendar()
|
||||
|
||||
// const { setCharacters } = useCharacters()
|
||||
const { isReadOnly } = storeToRefs(useCalendar())
|
||||
|
||||
const currentViewComponent: ComputedRef<Component> = computed<Component>(() => {
|
||||
switch (currentConfig.viewType) {
|
||||
@@ -53,8 +52,8 @@ onMounted(() => {
|
||||
<component :is="currentViewComponent" />
|
||||
|
||||
<CalendarSearch />
|
||||
<CalendarDialogUpdateEvent />
|
||||
<CalendarDialogDeleteEvent />
|
||||
<CalendarDialogUpdateEvent v-if="!isReadOnly" />
|
||||
<CalendarDialogDeleteEvent v-if="!isReadOnly" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@phosphor-icons/vue"
|
||||
|
||||
const { defaultDate, getFormattedDateTitle, jumpToDate, getRelativeString, revealEditEventModal, revealDeleteEventModal } = useCalendar()
|
||||
const { lastActiveEvent } = storeToRefs(useCalendar())
|
||||
const { lastActiveEvent, isReadOnly } = storeToRefs(useCalendar())
|
||||
|
||||
const props = defineProps<{
|
||||
event: CalendarEvent
|
||||
@@ -151,7 +151,7 @@ function deployDeleteModal() {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<menu class="absolute top-4 right-4" :class="cn({ 'top-6': event.hidden })">
|
||||
<menu v-if="!isReadOnly" class="absolute top-4 right-4" :class="cn({ 'top-6': event.hidden })">
|
||||
<UiPopover v-model:open="commandMenuOpened">
|
||||
<UiPopoverTrigger as-child>
|
||||
<UiButton size="icon" variant="outline" class="mix-blend-luminosity bg-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-600">
|
||||
|
||||
@@ -4,13 +4,14 @@ import { useCalendar } from "@/stores/CalendarStore"
|
||||
import { PhMagnifyingGlass } from "@phosphor-icons/vue"
|
||||
|
||||
const { revealAdvancedSearch } = useCalendar()
|
||||
const { isReadOnly } = storeToRefs(useCalendar())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="pt-4 border-slate-400 dark:border-slate-700 border-b-[1px]">
|
||||
<div class="px-6 flex justify-between">
|
||||
<menu class="flex items-center gap-2">
|
||||
<li>
|
||||
<li v-if="!isReadOnly">
|
||||
<CalendarDialogQuickCreateEvent />
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { z } from "zod"
|
||||
import type { CalendarEvent } from "./CalendarEvent"
|
||||
import { calendarMonthSchema, type CalendarMonth } from "./CalendarMonth"
|
||||
import { dateSchema, type RPGDate } from "./Date"
|
||||
import type { World } from "./World"
|
||||
|
||||
export type CalendarState = "published" | "draft" | "archived"
|
||||
|
||||
export interface CalendarConfig {
|
||||
months: CalendarMonth[]
|
||||
@@ -10,9 +13,12 @@ export interface CalendarConfig {
|
||||
|
||||
export interface Calendar extends CalendarConfig {
|
||||
id?: number
|
||||
shortId: string
|
||||
name: string
|
||||
events: CalendarEvent[]
|
||||
state: CalendarState
|
||||
color?: string
|
||||
world?: World
|
||||
}
|
||||
|
||||
export const postCalendarSchema = z.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
devtools: { enabled: false },
|
||||
devtools: { enabled: true },
|
||||
|
||||
modules: [
|
||||
"@nuxtjs/supabase",
|
||||
@@ -37,7 +37,10 @@ export default defineNuxtConfig({
|
||||
supabase: {
|
||||
redirectOptions: {
|
||||
login: "/",
|
||||
callback: "/my/"
|
||||
callback: "/my/",
|
||||
exclude: [
|
||||
"/calendars(/*)?"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
37
pages/calendars/[id].vue
Normal file
37
pages/calendars/[id].vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
import { PhCircleNotch } from "@phosphor-icons/vue";
|
||||
import type { Calendar } from "~/models/CalendarConfig";
|
||||
import type { Category } from "~/models/Category";
|
||||
|
||||
const route = useRoute()
|
||||
const shortId = route.params.id
|
||||
|
||||
const { data: calendarData, pending: calPending } = await useLazyFetch("/api/calendars/query", { key: `calendar-${shortId}`, query: { shortId, full: true } })
|
||||
const { data: catData, pending: catPending } = await useLazyFetch("/api/calendars/categories/query", { key: `categories-${shortId}` })
|
||||
|
||||
const cal = computed<Calendar>(() => calendarData?.value?.data as Calendar)
|
||||
const categories = computed<Category[]>(() => catData?.value?.data as Category[])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="calPending || catPending" class="h-full w-full grid place-items-center">
|
||||
<Head>
|
||||
<Title>{{ $t("entity.calendar.nameSingular") }}</Title>
|
||||
</Head>
|
||||
|
||||
<div class="grid gap-2 justify-items-center opacity-50">
|
||||
<p>
|
||||
{{ $t('entity.calendar.isLoading') }}
|
||||
</p>
|
||||
<PhCircleNotch size="50" class="animate-spin"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="cal && categories" class="h-full w-full">
|
||||
<Head>
|
||||
<Title>{{ cal.name }}</Title>
|
||||
</Head>
|
||||
|
||||
<Calendar :calendar-data="cal" :categories />
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import type { Calendar } from "~/models/CalendarConfig";
|
||||
|
||||
const querySchema = z.object({
|
||||
id: z.number({ coerce: true }).positive().int().optional(),
|
||||
shortId: z.string().optional(),
|
||||
full: z.boolean({ coerce: true }).optional()
|
||||
})
|
||||
|
||||
@@ -15,16 +16,20 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const partialFields = `
|
||||
id,
|
||||
shortId:short_id,
|
||||
name,
|
||||
today,
|
||||
months:calendar_months (*)
|
||||
months:calendar_months (*),
|
||||
state
|
||||
`
|
||||
|
||||
const fullFields = `
|
||||
id,
|
||||
shortId:short_id,
|
||||
name,
|
||||
today,
|
||||
months:calendar_months (*),
|
||||
state,
|
||||
events:calendar_events (
|
||||
id,
|
||||
title,
|
||||
@@ -36,6 +41,10 @@ export default defineEventHandler(async (event) => {
|
||||
wiki,
|
||||
category:calendar_event_categories!calendar_events_category_fkey (*),
|
||||
secondaryCategories:calendar_event_categories!calendar_event_categories_links (*)
|
||||
),
|
||||
world:worlds (
|
||||
id,
|
||||
gmId:gm_id
|
||||
)
|
||||
`
|
||||
|
||||
@@ -47,6 +56,10 @@ export default defineEventHandler(async (event) => {
|
||||
output = client.from("calendars").select(partialFields)
|
||||
}
|
||||
|
||||
if (query.shortId) {
|
||||
return output.eq("short_id", query.shortId).limit(1).single<Calendar>()
|
||||
}
|
||||
|
||||
if (query.id) {
|
||||
return output.eq("id", query.id).limit(1).single<Calendar>()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type DateDirectionTranslationKeys = {
|
||||
|
||||
export const useCalendar = defineStore("calendar", () => {
|
||||
const { t } = useI18n()
|
||||
const user = useSupabaseUser()
|
||||
|
||||
/**
|
||||
* Static calendar config
|
||||
@@ -45,7 +46,21 @@ export const useCalendar = defineStore("calendar", () => {
|
||||
"year"
|
||||
])
|
||||
|
||||
const activeCalendar = ref<{ id: number; name: string; today: RPGDate} | null>(null)
|
||||
const activeCalendar = ref<{ id: number; name: string; today: RPGDate, gmId: string | undefined } | null>(null)
|
||||
|
||||
const isReadOnly = ref<boolean>(true)
|
||||
|
||||
function setReadStatus(gmId: string) {
|
||||
// If the user is not logged in, or the calendar is not owned by the user, it's read-only
|
||||
isReadOnly.value = (!user) || (gmId !== user.value?.id)
|
||||
}
|
||||
|
||||
// Watch for user changes
|
||||
watch(user, () => {
|
||||
if (activeCalendar.value) {
|
||||
setReadStatus(activeCalendar.value.gmId!)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Month list (queried from API)
|
||||
@@ -59,11 +74,15 @@ export const useCalendar = defineStore("calendar", () => {
|
||||
activeCalendar.value = {
|
||||
id: calendarData.id,
|
||||
name: calendarData.name,
|
||||
today: calendarData.today
|
||||
today: calendarData.today,
|
||||
gmId: calendarData.world?.gmId
|
||||
}
|
||||
|
||||
setDefaultDate(activeCalendar.value.today)
|
||||
selectDate(activeCalendar.value.today)
|
||||
if (calendarData.world) {
|
||||
setReadStatus(activeCalendar.value.gmId!)
|
||||
}
|
||||
|
||||
if (!params.day) {
|
||||
params.day = defaultDate.value.day.toString()
|
||||
@@ -901,6 +920,8 @@ export const useCalendar = defineStore("calendar", () => {
|
||||
}
|
||||
|
||||
return {
|
||||
isReadOnly,
|
||||
setReadStatus,
|
||||
setActiveCalendar,
|
||||
activeCalendar,
|
||||
months,
|
||||
|
||||
@@ -7,6 +7,8 @@ create type public.app_permission as enum ('events.see.hidden', 'users.ban');
|
||||
create type public.app_role as enum ('sa', 'admin', 'moderator', 'user');
|
||||
create type public.app_colors as enum ('red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose', 'black', 'white');
|
||||
|
||||
create type public.calendar_state as enum ('published', 'draft', 'archived');
|
||||
|
||||
-- DATA STRUCTURES
|
||||
-- Users
|
||||
create table public.users (
|
||||
@@ -44,12 +46,24 @@ create table public.worlds (
|
||||
);
|
||||
comment on table public.worlds is 'Worlds belonging to a single user ; a game master.';
|
||||
|
||||
-- World Players (join table)
|
||||
create table public.world_players (
|
||||
world_id bigint references public.worlds on delete cascade,
|
||||
player_id uuid references public.users on delete cascade,
|
||||
joined_at timestamptz default now(),
|
||||
primary key (world_id, player_id)
|
||||
);
|
||||
comment on table public.world_players is 'Players (users) that belong to specific worlds';
|
||||
comment on column public.world_players.joined_at is 'Timestamp when the player joined the world';
|
||||
|
||||
-- World Calendars
|
||||
create table public.calendars (
|
||||
id bigint generated by default as identity primary key,
|
||||
short_id text unique,
|
||||
name text not null,
|
||||
today json not null,
|
||||
color app_colors default 'black',
|
||||
state calendar_state default 'draft',
|
||||
world_id bigint references public.worlds on delete cascade not null
|
||||
);
|
||||
comment on table public.calendars is 'Calendar settings and configuration attached to a single world.';
|
||||
@@ -187,6 +201,10 @@ create policy "Allow GMs to edit their worlds" on public.worlds for update using
|
||||
create policy "Allow GMs to delete their worlds" on public.worlds for delete using ( auth.uid() = gm_id );
|
||||
|
||||
-- Calendar policies
|
||||
create policy "Allow anonymous access to published calendars" on public.calendars
|
||||
for select
|
||||
using (state = 'published');
|
||||
|
||||
create policy "Allow GMs to see their calendars" on public.calendars for select using (
|
||||
exists (
|
||||
select 1 from worlds
|
||||
@@ -213,6 +231,17 @@ create policy "Allow GMs to delete their calendars" on public.calendars for dele
|
||||
);
|
||||
|
||||
-- Month policies
|
||||
create policy "Allow anonymous access to months in published calendars" ON public.calendar_months
|
||||
for select
|
||||
using (
|
||||
exists (
|
||||
select 1
|
||||
from public.calendars
|
||||
where calendars.id = calendar_months.calendar_id
|
||||
and calendars.state = 'published'
|
||||
)
|
||||
);
|
||||
|
||||
create policy "Allow GMs to see their calendar's months"
|
||||
on public.calendar_months
|
||||
for select
|
||||
@@ -267,6 +296,17 @@ create policy "Allow GMs to delete their calendar's months"
|
||||
);
|
||||
|
||||
-- Event policies
|
||||
create policy "Allow anonymous access to non-hidden events in published calendars" ON public.calendar_events
|
||||
for select
|
||||
using (
|
||||
not hidden and exists (
|
||||
select 1
|
||||
from public.calendars
|
||||
where calendars.id = calendar_events.calendar_id
|
||||
and calendars.state = 'published'
|
||||
)
|
||||
);
|
||||
|
||||
create policy "Allow GMs to see their events"
|
||||
on public.calendar_events
|
||||
for select
|
||||
@@ -342,8 +382,8 @@ create policy "Allow individual update access for GMs" on public.characters for
|
||||
|
||||
-- Categories are public to view but not to insert
|
||||
-- Needs to be refactored maybe, if in the future we want a default set AND user defined ones
|
||||
create policy "Allow logged-in read access" on public.calendar_event_categories for select using ( auth.role() = 'authenticated' );
|
||||
create policy "Allow logged-in read access" on public.calendar_event_categories_links for select using ( auth.role() = 'authenticated' );
|
||||
create policy "Allow all read access" on public.calendar_event_categories for select to authenticated, anon using ( true );
|
||||
create policy "Allow all read access" on public.calendar_event_categories_links for select to authenticated, anon using ( true );
|
||||
create policy "Allow logged-in read access" on public.character_categories for select using ( auth.role() = 'authenticated' );
|
||||
create policy "Allow logged-in read access" on public.character_categories_links for select using ( auth.role() = 'authenticated' );
|
||||
|
||||
@@ -391,3 +431,48 @@ begin
|
||||
return user_id;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
-- Add short IDs to calendars
|
||||
-- Function to generate YouTube-like IDs
|
||||
CREATE OR REPLACE FUNCTION public.generate_short_id()
|
||||
RETURNS text AS $$
|
||||
DECLARE
|
||||
characters text := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
result text := '';
|
||||
i integer;
|
||||
BEGIN
|
||||
FOR i IN 1..11 LOOP
|
||||
result := result || substr(characters, floor(random() * length(characters) + 1)::integer, 1);
|
||||
END LOOP;
|
||||
RETURN result;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql VOLATILE;
|
||||
|
||||
-- Trigger function to set unique short ID
|
||||
CREATE OR REPLACE FUNCTION public.set_calendar_short_id()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
new_id text;
|
||||
attempts integer := 0;
|
||||
BEGIN
|
||||
LOOP
|
||||
new_id := generate_short_id();
|
||||
BEGIN
|
||||
NEW.short_id = new_id;
|
||||
RETURN NEW;
|
||||
EXCEPTION WHEN unique_violation THEN
|
||||
attempts := attempts + 1;
|
||||
IF attempts > 5 THEN
|
||||
RAISE EXCEPTION 'Could not generate unique ID after 5 attempts';
|
||||
END IF;
|
||||
CONTINUE;
|
||||
END;
|
||||
END LOOP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger
|
||||
CREATE TRIGGER set_calendar_short_id
|
||||
BEFORE INSERT ON calendars
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.set_calendar_short_id();
|
||||
|
||||
Reference in New Issue
Block a user