Merge pull request #56 from AlexisNP/features/calendar-public-links

Features/calendar public links
This commit is contained in:
AlexisNP
2024-12-18 20:17:43 +01:00
committed by GitHub
9 changed files with 180 additions and 15 deletions

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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({

View File

@@ -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(/*)?"
]
}
},
@@ -57,4 +60,4 @@ export default defineNuxtConfig({
fallback: "dark",
},
eslint: {}
})
})

37
pages/calendars/[id].vue Normal file
View 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>

View File

@@ -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>()
}

View File

@@ -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,

View File

@@ -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();