diff --git a/README.md b/README.md index c51b1f3..38dbc33 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,61 @@ # Root -Team collaboration platform. +Team collaboration platform built with SvelteKit, Supabase, and TailwindCSS. ## Quick Start ```bash -# Install npm install - -# Set up environment -cp .env.example .env -# Fill in your Supabase credentials in .env - -# Run migrations in Supabase Dashboard > SQL Editor -# Copy & run each file in supabase/migrations/ in order (001, 002, 003...) - -# Start dev server +cp .env.example .env # Fill in your credentials npm run dev ``` Open http://localhost:5173 -## Docker +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `PUBLIC_SUPABASE_URL` | Yes | Supabase project URL | +| `PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anonymous key | +| `GOOGLE_API_KEY` | No | Google Maps API key (for map module) | +| `GOOGLE_SERVICE_ACCOUNT_KEY` | No | Google Calendar push (JSON key) | +| `MATRIX_HOMESERVER_URL` | No | Matrix/Synapse homeserver URL | +| `MATRIX_ADMIN_TOKEN` | No | Synapse admin token for auto-provisioning | +| `RESEND_API_KEY` | No | Resend.com API key for invite emails | +| `RESEND_FROM_EMAIL` | No | Verified sender email (e.g. `noreply@yourdomain.com`) | + +## Database + +Migrations are in `supabase/migrations/`. Push them with: ```bash -# Production -docker-compose up app +npm run db:push # Push pending migrations +npm run db:types # Regenerate TypeScript types +npm run db:migrate # Both in one step +``` -# Development +## Production Deployment + +### Docker + +```bash +docker-compose up -d app +``` + +The app runs on port 3000 with a `/health` endpoint for monitoring. + +### Manual + +```bash +npm run build +node build +``` + +Set `PORT` (default 3000), `HOST` (default 0.0.0.0), and `NODE_ENV=production`. + +### Development (Docker) + +```bash docker-compose up dev ``` diff --git a/docker-compose.yml b/docker-compose.yml index 6ca6515..fcd0c2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,18 @@ services: - NODE_ENV=production - PORT=3000 - HOST=0.0.0.0 - # Supabase configuration (add your values) + # Supabase - PUBLIC_SUPABASE_URL=${PUBLIC_SUPABASE_URL} - PUBLIC_SUPABASE_ANON_KEY=${PUBLIC_SUPABASE_ANON_KEY} + # Google + - GOOGLE_API_KEY=${GOOGLE_API_KEY} + - GOOGLE_SERVICE_ACCOUNT_KEY=${GOOGLE_SERVICE_ACCOUNT_KEY} + # Matrix + - MATRIX_HOMESERVER_URL=${MATRIX_HOMESERVER_URL} + - MATRIX_ADMIN_TOKEN=${MATRIX_ADMIN_TOKEN} + # Email (Resend) + - RESEND_API_KEY=${RESEND_API_KEY} + - RESEND_FROM_EMAIL=${RESEND_FROM_EMAIL} restart: unless-stopped healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] diff --git a/messages/en.json b/messages/en.json index b3e6a43..999193b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -92,6 +92,35 @@ "calendar_event_date": "Date", "calendar_event_time": "Time", "calendar_event_desc": "Description", + "calendar_event_desc_placeholder": "Add a description...", + "calendar_event_all_day": "All day event", + "calendar_event_all_day_label": "All day", + "calendar_event_start": "Start", + "calendar_event_end": "End", + "calendar_event_color": "Color", + "calendar_event_no_title": "(No title)", + "calendar_event_fallback_title": "Event", + "calendar_event_google": "Google Calendar Event", + "calendar_event_synced": "Synced to Google Calendar", + "calendar_event_local": "Local Event", + "calendar_open_google": "Open in Google Calendar", + "calendar_sync_google": "Sync to Google Calendar", + "calendar_delete_event": "Delete Event", + "calendar_edit_event_btn": "Edit Event", + "calendar_today": "Today", + "calendar_previous": "Previous", + "calendar_next": "Next", + "calendar_view_day": "Day", + "calendar_view_week": "Week", + "calendar_view_month": "Month", + "calendar_day_mon": "Mon", + "calendar_day_tue": "Tue", + "calendar_day_wed": "Wed", + "calendar_day_thu": "Thu", + "calendar_day_fri": "Fri", + "calendar_day_sat": "Sat", + "calendar_day_sun": "Sun", + "calendar_no_events": "No events for this day", "settings_title": "Settings", "settings_tab_general": "General", "settings_tab_members": "Members", @@ -992,4 +1021,4 @@ "settings_transfer_confirm": "Transfer ownership to {name}? You will be demoted to admin. This action is immediate.", "toast_error_transfer_ownership": "Failed to transfer ownership", "toast_success_transfer_ownership": "Ownership transferred to {name}" -} +} \ No newline at end of file diff --git a/messages/et.json b/messages/et.json index 30678b3..e0dd00d 100644 --- a/messages/et.json +++ b/messages/et.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "https://inlang.com/schema/inlang-message-format", "app_name": "Root", "nav_files": "Failid", @@ -91,6 +91,35 @@ "calendar_event_date": "Kuupäev", "calendar_event_time": "Kellaaeg", "calendar_event_desc": "Kirjeldus", + "calendar_event_desc_placeholder": "Lisa kirjeldus...", + "calendar_event_all_day": "Kogu päeva sündmus", + "calendar_event_all_day_label": "Kogu päev", + "calendar_event_start": "Algus", + "calendar_event_end": "Lõpp", + "calendar_event_color": "Värv", + "calendar_event_no_title": "(Pealkirjata)", + "calendar_event_fallback_title": "Sündmus", + "calendar_event_google": "Google Calendar'i sündmus", + "calendar_event_synced": "Sünkroniseeritud Google Calendar'iga", + "calendar_event_local": "Kohalik sündmus", + "calendar_open_google": "Ava Google Calendar'is", + "calendar_sync_google": "Sünkroniseeri Google Calendar'iga", + "calendar_delete_event": "Kustuta sündmus", + "calendar_edit_event_btn": "Muuda sündmust", + "calendar_today": "Täna", + "calendar_previous": "Eelmine", + "calendar_next": "Järgmine", + "calendar_view_day": "Päev", + "calendar_view_week": "Nädal", + "calendar_view_month": "Kuu", + "calendar_day_mon": "E", + "calendar_day_tue": "T", + "calendar_day_wed": "K", + "calendar_day_thu": "N", + "calendar_day_fri": "R", + "calendar_day_sat": "L", + "calendar_day_sun": "P", + "calendar_no_events": "Sellel päeval sündmusi pole", "settings_title": "Seaded", "settings_tab_general": "Üldine", "settings_tab_members": "Liikmed", @@ -992,4 +1021,4 @@ "settings_transfer_confirm": "Kanna omanikõigused üle kasutajale {name}? Sind alandatakse adminiks. See toiming on kohene.", "toast_error_transfer_ownership": "Omanikõiguste ülekandmine ebaõnnestus", "toast_success_transfer_ownership": "Omanikõigused kanti üle kasutajale {name}" -} +} \ No newline at end of file diff --git a/src/lib/components/calendar/Calendar.svelte b/src/lib/components/calendar/Calendar.svelte index 1e5fff9..202747d 100644 --- a/src/lib/components/calendar/Calendar.svelte +++ b/src/lib/components/calendar/Calendar.svelte @@ -1,6 +1,7 @@ - Calendar - {data.org.name} | root + {m.calendar_title()} - {data.org.name} | root
@@ -527,7 +527,7 @@ (showEventModal = false)} - title={selectedEvent?.title ?? "Event"} + title={selectedEvent?.title ?? m.calendar_event_fallback_title()} > {#if selectedEvent}
@@ -612,10 +612,10 @@
{selectedEvent.id.startsWith("google-") - ? "Google Calendar Event" + ? m.calendar_event_google() : selectedEvent.google_event_id - ? "Synced to Google Calendar" - : "Local Event"} + ? m.calendar_event_synced() + : m.calendar_event_local()}
@@ -651,7 +651,7 @@ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" /> - Open in Google Calendar + {m.calendar_open_google()} {/if} @@ -666,9 +666,11 @@ onclick={handleDeleteEvent} loading={isDeleting} > - Delete Event + {m.calendar_delete_event()} - + {/if} @@ -700,14 +702,14 @@ class="flex items-center gap-2 text-sm text-light cursor-pointer" > - All day event + {m.calendar_event_all_day()} {#if !eventAllDay}
Start{m.calendar_event_start()}
End{m.calendar_event_end()}
- Color + {m.calendar_event_color()}
{#each EVENT_COLORS as color}
{/if} diff --git a/src/routes/[orgSlug]/events/+page.server.ts b/src/routes/[orgSlug]/events/+page.server.ts index d117a49..9bed1e5 100644 --- a/src/routes/[orgSlug]/events/+page.server.ts +++ b/src/routes/[orgSlug]/events/+page.server.ts @@ -1,21 +1,12 @@ -import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; +import type { OrgLayoutData } from '$lib/types/layout'; import { fetchEvents } from '$lib/api/events'; import { createLogger } from '$lib/utils/logger'; const log = createLogger('page.events'); -export const load: PageServerLoad = async ({ params, locals, url }) => { - const { session, user } = await locals.safeGetSession(); - if (!session || !user) error(401, 'Unauthorized'); - - const { data: org } = await locals.supabase - .from('organizations') - .select('id') - .eq('slug', params.orgSlug) - .single(); - - if (!org) error(404, 'Organization not found'); +export const load: PageServerLoad = async ({ parent, locals, url }) => { + const { org } = await parent() as OrgLayoutData; const statusFilter = url.searchParams.get('status') || 'all'; diff --git a/src/routes/[orgSlug]/events/+page.svelte b/src/routes/[orgSlug]/events/+page.svelte index 59a18d7..f96350c 100644 --- a/src/routes/[orgSlug]/events/+page.svelte +++ b/src/routes/[orgSlug]/events/+page.svelte @@ -60,6 +60,7 @@ let newEventEndDate = $state(""); let newEventVenue = $state(""); let newEventVenueAddress = $state(""); + // svelte-ignore state_referenced_locally let newEventColor = $state(data.org.default_event_color || "#00A3E0"); let creating = $state(false); diff --git a/src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts b/src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts index 5fad0d5..c045d27 100644 --- a/src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts +++ b/src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts @@ -1,16 +1,14 @@ import { error } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; +import type { OrgLayoutData } from '$lib/types/layout'; import { fetchEventBySlug, fetchEventMembers, fetchEventRoles, fetchEventDepartments } from '$lib/api/events'; import { createLogger } from '$lib/utils/logger'; const log = createLogger('page.event-detail'); export const load: LayoutServerLoad = async ({ params, locals, parent }) => { - const { session, user } = await locals.safeGetSession(); - if (!session || !user) error(401, 'Unauthorized'); - - const parentData = await parent() as { org: { id: string; name: string; slug: string } }; - const orgId = parentData.org.id; + const { org } = await parent() as OrgLayoutData; + const orgId = org.id; try { const event = await fetchEventBySlug(locals.supabase, orgId, params.eventSlug); diff --git a/src/routes/[orgSlug]/events/[eventSlug]/contacts/+page.svelte b/src/routes/[orgSlug]/events/[eventSlug]/contacts/+page.svelte index c071657..f55b289 100644 --- a/src/routes/[orgSlug]/events/[eventSlug]/contacts/+page.svelte +++ b/src/routes/[orgSlug]/events/[eventSlug]/contacts/+page.svelte @@ -43,6 +43,7 @@ // Expanded row let expandedId = $state(null); + // svelte-ignore state_referenced_locally let contacts = $state(data.orgContacts); const filteredContacts = $derived( @@ -224,12 +225,23 @@ class="bg-surface/30 rounded-2xl border border-light/5 overflow-hidden" > {#each filteredContacts as contact (contact.id)} - +
(expandedId = expandedId === contact.id ? null : contact.id)} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + expandedId = + expandedId === contact.id + ? null + : contact.id; + } + }} + role="button" + tabindex="0" >
@@ -354,16 +366,17 @@ {#if showModal} - +
(showModal = false)} onkeydown={(e) => e.key === "Escape" && (showModal = false)} > - +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} >

{editingContact ? "Edit Contact" : "Add Contact"} diff --git a/src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.server.ts b/src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.server.ts index 1fdde04..8f3f7b6 100644 --- a/src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.server.ts +++ b/src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.server.ts @@ -11,12 +11,9 @@ import { createLogger } from '$lib/utils/logger'; const log = createLogger('page.department-dashboard'); export const load: PageServerLoad = async ({ params, locals, parent }) => { - const { session, user } = await locals.safeGetSession(); - if (!session || !user) error(401, 'Unauthorized'); - const parentData = await parent(); - const event = (parentData as any).event; - const departments = (parentData as any).eventDepartments ?? []; + const event = (parentData as Record).event as { id: string }; + const departments = ((parentData as Record).eventDepartments ?? []) as { id: string; name: string }[]; const department = departments.find((d: any) => d.id === params.deptId); if (!department) error(404, 'Department not found'); diff --git a/src/routes/[orgSlug]/events/[eventSlug]/finances/+page.svelte b/src/routes/[orgSlug]/events/[eventSlug]/finances/+page.svelte index 39cb9e7..2839dd4 100644 --- a/src/routes/[orgSlug]/events/[eventSlug]/finances/+page.svelte +++ b/src/routes/[orgSlug]/events/[eventSlug]/finances/+page.svelte @@ -938,16 +938,17 @@ {#if showAllocModal} - +
(showAllocModal = false)} onkeydown={(e) => e.key === "Escape" && (showAllocModal = false)} > - +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} >

{editingAllocId ? "Edit Allocation" : "Allocate Sponsor Funds"} diff --git a/src/routes/[orgSlug]/events/[eventSlug]/sponsors/+page.svelte b/src/routes/[orgSlug]/events/[eventSlug]/sponsors/+page.svelte index 23a2d10..48733b6 100644 --- a/src/routes/[orgSlug]/events/[eventSlug]/sponsors/+page.svelte +++ b/src/routes/[orgSlug]/events/[eventSlug]/sponsors/+page.svelte @@ -53,6 +53,7 @@ let formNotes = $state(""); let saving = $state(false); + // svelte-ignore state_referenced_locally let sponsors = $state(data.sponsors); const tiers = $derived(data.sponsorTiers); const departments = $derived(data.departments); @@ -396,16 +397,17 @@ {#if showModal} - +
(showModal = false)} onkeydown={(e) => e.key === "Escape" && (showModal = false)} > - +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} >

{editingSponsor ? "Edit Sponsor" : "Add Sponsor"} diff --git a/src/routes/[orgSlug]/events/[eventSlug]/team/+page.server.ts b/src/routes/[orgSlug]/events/[eventSlug]/team/+page.server.ts index 37b4050..1bee6b9 100644 --- a/src/routes/[orgSlug]/events/[eventSlug]/team/+page.server.ts +++ b/src/routes/[orgSlug]/events/[eventSlug]/team/+page.server.ts @@ -1,5 +1,8 @@ import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ parent }) => { - return await parent(); + const data = await parent(); + // Resolve deferred members promise so the page component gets a plain array + const members = await data.members; + return { ...data, members }; }; diff --git a/src/routes/[orgSlug]/kanban/+page.server.ts b/src/routes/[orgSlug]/kanban/+page.server.ts index 7e954e2..799f11d 100644 --- a/src/routes/[orgSlug]/kanban/+page.server.ts +++ b/src/routes/[orgSlug]/kanban/+page.server.ts @@ -5,20 +5,25 @@ import { createLogger } from '$lib/utils/logger'; const log = createLogger('page.kanban'); export const load: PageServerLoad = async ({ parent, locals }) => { - const { org } = await parent() as OrgLayoutData; + const parentData = await parent() as OrgLayoutData; + const { org } = parentData; const { supabase } = locals; - const { data: boards, error } = await supabase - .from('kanban_boards') - .select('*') - .eq('org_id', org.id) - .order('created_at'); + const [{ data: boards, error }, members] = await Promise.all([ + supabase + .from('kanban_boards') + .select('*') + .eq('org_id', org.id) + .order('created_at'), + parentData.members, + ]); if (error) { log.error('Failed to load kanban boards', { error, data: { orgId: org.id } }); } return { - boards: boards ?? [] + boards: boards ?? [], + members, }; };