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}
- Sync to Google Calendar
+ {m.calendar_sync_google()}
{/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,
};
};