Mega push vol 7 mvp lesgoooo

This commit is contained in:
AlacrisDevs
2026-02-07 21:47:47 +02:00
parent dcee479839
commit d22847f555
75 changed files with 7685 additions and 892 deletions

View File

@@ -2,7 +2,7 @@
import { page } from '$app/state';
import { locales, localizeHref } from '$lib/paraglide/runtime';
import "./layout.css";
import favicon from "$lib/assets/favicon.svg";
import { createClient } from "$lib/supabase";
import { setContext } from "svelte";
import { ToastContainer } from "$lib/components/ui";
@@ -13,7 +13,6 @@
setContext("supabase", supabase);
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}
<ToastContainer />

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { getContext } from "svelte";
import { Button, Modal, Input } from "$lib/components/ui";
import { Modal } from "$lib/components/ui";
import { createOrganization, generateSlug } from "$lib/api/organizations";
import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
@@ -24,6 +24,7 @@
const supabase = getContext<SupabaseClient<Database>>("supabase");
// svelte-ignore state_referenced_locally
let organizations = $state(data.organizations);
$effect(() => {
organizations = data.organizations;
@@ -62,14 +63,15 @@
<!-- Header -->
<header class="border-b border-light/5">
<div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="material-symbols-rounded text-primary" style="font-size: 28px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 28;">hub</span>
<span class="font-heading text-h4 text-white">Root</span>
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 bg-primary/10 rounded-xl flex items-center justify-center">
<span class="material-symbols-rounded text-primary" style="font-size: 18px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 18;">hub</span>
</div>
<span class="font-heading text-body text-white">Root</span>
</div>
<div class="flex items-center gap-2">
<a href="/style" class="px-3 py-1.5 text-[12px] text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors">Style Guide</a>
<form method="POST" action="/auth/logout">
<Button variant="tertiary" size="sm" type="submit">Sign Out</Button>
<button type="submit" class="px-3 py-1.5 text-body-sm text-light/40 hover:text-white hover:bg-dark/50 rounded-xl transition-colors">Sign Out</button>
</form>
</div>
</div>
@@ -81,26 +83,37 @@
<h2 class="font-heading text-h3 text-white">Your Organizations</h2>
<p class="text-body-sm text-light/40 mt-1">Select an organization to get started</p>
</div>
<Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>New Organization</Button>
<button
class="flex items-center gap-1.5 px-3 py-2 bg-primary text-background rounded-xl text-body-sm font-body hover:bg-primary-hover transition-colors"
onclick={() => (showCreateModal = true)}
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">add</span>
New Organization
</button>
</div>
{#if organizations.length === 0}
<div class="bg-dark/30 border border-light/5 rounded-xl p-12 text-center">
<span class="material-symbols-rounded text-light/20 mb-3 block" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">groups</span>
<div class="bg-dark/30 border border-light/5 rounded-2xl p-12 text-center">
<div class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-light/5 flex items-center justify-center">
<span class="material-symbols-rounded text-light/20" style="font-size: 28px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 28;">groups</span>
</div>
<h3 class="font-heading text-body text-white mb-1">No organizations yet</h3>
<p class="text-body-sm text-light/40 mb-6">Create your first organization to start collaborating</p>
<Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>Create Organization</Button>
<button
class="px-4 py-2 bg-primary text-background rounded-xl text-body-sm font-body hover:bg-primary-hover transition-colors"
onclick={() => (showCreateModal = true)}
>Create Organization</button>
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{#each organizations as org}
<a href="/{org.slug}" class="block group">
<div class="bg-dark/30 border border-light/5 hover:border-light/10 rounded-xl p-5 transition-all h-full">
<div class="bg-dark/30 border border-light/5 hover:border-primary/30 rounded-2xl p-5 transition-all h-full">
<div class="flex items-start justify-between mb-3">
<div class="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center text-primary font-heading text-body">
{org.name.charAt(0).toUpperCase()}
</div>
<span class="text-[10px] px-2 py-0.5 bg-light/5 rounded-md text-light/40 capitalize font-body">{org.role}</span>
<span class="text-[10px] px-2 py-0.5 bg-light/5 rounded-lg text-light/40 capitalize font-body">{org.role}</span>
</div>
<h3 class="font-heading text-body-sm text-white group-hover:text-primary transition-colors">{org.name}</h3>
<p class="text-[11px] text-light/30 mt-0.5 font-body">/{org.slug}</p>
@@ -117,29 +130,32 @@
onClose={() => (showCreateModal = false)}
title="Create Organization"
>
<div class="space-y-4">
<Input
label="Organization Name"
bind:value={newOrgName}
placeholder="e.g. Acme Inc"
/>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label for="org-name" class="text-body-sm text-light/60 font-body">Organization Name</label>
<input
id="org-name"
type="text"
bind:value={newOrgName}
placeholder="e.g. Acme Inc"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
{#if newOrgName}
<p class="text-sm text-light/50">
URL: <span class="text-light/70"
>/{generateSlug(newOrgName)}</span
>
<p class="text-body-sm text-light/40">
URL: <span class="text-white font-body">/{generateSlug(newOrgName)}</span>
</p>
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
>Cancel</Button
>
<Button
onclick={handleCreateOrg}
<div class="flex items-center justify-end gap-3 pt-2 border-t border-light/5">
<button type="button" class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (showCreateModal = false)}>Cancel</button>
<button
type="button"
disabled={!newOrgName.trim() || creating}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleCreateOrg}
>
{creating ? "Creating..." : "Create"}
</Button>
</button>
</div>
</div>
</Modal>

View File

@@ -36,11 +36,17 @@
const supabase = getContext<SupabaseClient<Database>>("supabase");
// Profile state
// svelte-ignore state_referenced_locally
let fullName = $state(data.profile.full_name ?? "");
// svelte-ignore state_referenced_locally
let avatarUrl = $state(data.profile.avatar_url ?? null);
// svelte-ignore state_referenced_locally
let phone = $state(data.profile.phone ?? "");
// svelte-ignore state_referenced_locally
let discordHandle = $state(data.profile.discord_handle ?? "");
// svelte-ignore state_referenced_locally
let shirtSize = $state(data.profile.shirt_size ?? "");
// svelte-ignore state_referenced_locally
let hoodieSize = $state(data.profile.hoodie_size ?? "");
let isSaving = $state(false);
let isUploading = $state(false);
@@ -49,8 +55,11 @@
const clothingSizes = ["XS", "S", "M", "L", "XL", "XXL", "3XL"];
// Preferences state
// svelte-ignore state_referenced_locally
let theme = $state(data.preferences?.theme ?? "dark");
// svelte-ignore state_referenced_locally
let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0");
// svelte-ignore state_referenced_locally
let useOrgTheme = $state(data.preferences?.use_org_theme ?? true);
let currentLocale = $state<(typeof locales)[number]>(getLocale());
@@ -250,7 +259,7 @@
<div class="flex-1 p-6 overflow-auto">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Profile Section -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
<div class="bg-dark/30 border border-light/5 rounded-2xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white">
{m.account_profile()}
</h2>
@@ -326,7 +335,7 @@
</div>
<!-- Contact & Sizing Section -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
<div class="bg-dark/30 border border-light/5 rounded-2xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white">
{m.account_contact_info()}
</h2>
@@ -378,7 +387,7 @@
</div>
<!-- Appearance Section -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
<div class="bg-dark/30 border border-light/5 rounded-2xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white">
{m.account_appearance()}
</h2>
@@ -404,17 +413,17 @@
{#each accentColors as color}
<button
type="button"
class="w-8 h-8 rounded-full border-2 transition-all {accentColor ===
class="w-6 h-6 rounded-full border-2 transition-all {accentColor ===
color.value
? 'border-white scale-110'
: 'border-transparent hover:scale-105'}"
: 'border-transparent hover:border-light/30'}"
style="background-color: {color.value}"
title={color.label}
onclick={() => (accentColor = color.value)}
></button>
{/each}
<label
class="w-8 h-8 rounded-full border-2 border-dashed border-light/30 hover:border-light/60 transition-all cursor-pointer flex items-center justify-center overflow-hidden"
class="w-6 h-6 rounded-full border-2 border-dashed border-light/20 hover:border-light/40 transition-all cursor-pointer flex items-center justify-center overflow-hidden"
title="Custom color"
>
<input
@@ -423,8 +432,8 @@
bind:value={accentColor}
/>
<span
class="material-symbols-rounded text-light/40"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
class="material-symbols-rounded text-light/30"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>
colorize
</span>
@@ -490,7 +499,7 @@
</div>
<!-- Security & Sessions Section -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
<div class="bg-dark/30 border border-light/5 rounded-2xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white">
{m.account_security()}
</h2>

View File

@@ -33,6 +33,7 @@
const supabase = getContext<SupabaseClient<Database>>("supabase");
const log = createLogger("page.calendar");
// svelte-ignore state_referenced_locally
let events = $state(data.events);
$effect(() => {
events = data.events;
@@ -735,6 +736,7 @@
: ''}"
style="background-color: {color}"
onclick={() => (eventColor = color)}
aria-label="Color {color}"
></button>
{/each}
</div>

View File

@@ -12,6 +12,7 @@
let { data }: Props = $props();
// svelte-ignore state_referenced_locally
let documents = $state(data.documents);
$effect(() => {
documents = data.documents;

View File

@@ -13,6 +13,7 @@
let { data }: Props = $props();
// svelte-ignore state_referenced_locally
let documents = $state(data.documents);
$effect(() => {
documents = data.documents;

View File

@@ -30,41 +30,11 @@
icon: "dashboard",
exact: true,
},
{
href: `${basePath}/tasks`,
label: m.events_mod_tasks(),
icon: "task_alt",
},
{
href: `${basePath}/files`,
label: m.events_mod_files(),
icon: "folder",
},
{
href: `${basePath}/schedule`,
label: m.events_mod_schedule(),
icon: "calendar_today",
},
{
href: `${basePath}/budget`,
label: m.events_mod_budget(),
icon: "account_balance_wallet",
},
{
href: `${basePath}/guests`,
label: m.events_mod_guests(),
icon: "groups",
},
{
href: `${basePath}/team`,
label: m.events_mod_team(),
icon: "badge",
},
{
href: `${basePath}/sponsors`,
label: m.events_mod_sponsors(),
icon: "handshake",
},
]);
function isModuleActive(href: string, exact?: boolean): boolean {
@@ -162,6 +132,30 @@
{/if}
</a>
{/each}
<!-- Departments -->
{#if data.eventDepartments.length > 0}
<p class="text-[10px] uppercase tracking-wider text-light/30 px-3 mt-3 mb-1">
Departments
</p>
{#each data.eventDepartments as dept}
<a
href="{basePath}/dept/{dept.id}"
class="flex items-center gap-2.5 px-3 py-2 rounded-xl text-body-sm font-body transition-colors {isModuleActive(`${basePath}/dept/${dept.id}`)
? 'bg-primary text-background'
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
>
<span
class="w-2.5 h-2.5 rounded-full shrink-0"
style="background-color: {dept.color}"
></span>
<span class="flex-1 truncate">{dept.name}</span>
{#if isNavigatingToModule(`${basePath}/dept/${dept.id}`)}
<span class="block w-3.5 h-3.5 border-2 border-background/30 border-t-background rounded-full animate-spin shrink-0"></span>
{/if}
</a>
{/each}
{/if}
</nav>
<!-- Event Team Preview -->

View File

@@ -0,0 +1,61 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { fetchDashboard, fetchChecklists, fetchNotes } from '$lib/api/department-dashboard';
import { fetchStages, fetchBlocks } from '$lib/api/schedule';
import { fetchContacts } from '$lib/api/contacts';
import { fetchBudgetCategories, fetchBudgetItems } from '$lib/api/budget';
import { fetchSponsorTiers, fetchSponsors, fetchAllDeliverables } from '$lib/api/sponsors';
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 department = departments.find((d: any) => d.id === params.deptId);
if (!department) error(404, 'Department not found');
try {
const [dashboard, checklists, notes, scheduleStages, scheduleBlocks, contacts, budgetCategories, budgetItems, sponsorTiers, sponsors] = await Promise.all([
fetchDashboard(locals.supabase, params.deptId),
fetchChecklists(locals.supabase, params.deptId),
fetchNotes(locals.supabase, params.deptId),
fetchStages(locals.supabase, params.deptId).catch(() => []),
fetchBlocks(locals.supabase, params.deptId).catch(() => []),
fetchContacts(locals.supabase, params.deptId).catch(() => []),
fetchBudgetCategories(locals.supabase, params.deptId).catch(() => []),
fetchBudgetItems(locals.supabase, params.deptId).catch(() => []),
fetchSponsorTiers(locals.supabase, params.deptId).catch(() => []),
fetchSponsors(locals.supabase, params.deptId).catch(() => []),
]);
// Fetch deliverables for all sponsors
const sponsorIds = (sponsors as any[]).map((s: any) => s.id);
const sponsorDeliverables = sponsorIds.length > 0
? await fetchAllDeliverables(locals.supabase, sponsorIds).catch(() => [])
: [];
return {
department,
dashboard,
checklists,
notes,
scheduleStages,
scheduleBlocks,
contacts,
budgetCategories,
budgetItems,
sponsorTiers,
sponsors,
sponsorDeliverables,
};
} catch (e: any) {
log.error('Failed to load department dashboard', { error: e, data: { deptId: params.deptId } });
error(500, 'Failed to load department dashboard');
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,7 @@
const supabase = getContext<SupabaseClient<Database>>("supabase");
// svelte-ignore state_referenced_locally
let taskColumns = $state<TaskColumnWithTasks[]>(data.taskColumns);
let realtimeChannel = $state<RealtimeChannel | null>(null);
let optimisticMoveIds = new Set<string>();

View File

@@ -49,8 +49,11 @@
);
// Local mutable state
// svelte-ignore state_referenced_locally
let teamMembers = $state<EventMemberWithDetails[]>(data.eventMembers);
// svelte-ignore state_referenced_locally
let roles = $state<EventRole[]>(data.eventRoles);
// svelte-ignore state_referenced_locally
let departments = $state<EventDepartment[]>(data.eventDepartments);
$effect(() => {
@@ -118,8 +121,29 @@
let editingDept = $state<EventDepartment | null>(null);
let deptName = $state("");
let deptColor = $state("#00A3E0");
type ModuleType = "kanban" | "files" | "checklist" | "notes" | "schedule" | "contacts" | "budget" | "sponsors";
let deptModules = $state<ModuleType[]>(["kanban", "files", "checklist"]);
let savingDept = $state(false);
const allModules = [
{ id: "kanban", label: "Kanban", icon: "view_kanban", color: "#6366f1" },
{ id: "files", label: "Files", icon: "folder", color: "#F59E0B" },
{ id: "checklist", label: "Checklist", icon: "checklist", color: "#10B981" },
{ id: "notes", label: "Notes", icon: "description", color: "#8B5CF6" },
{ id: "schedule", label: "Schedule", icon: "calendar_today", color: "#EC4899" },
{ id: "contacts", label: "Contacts", icon: "contacts", color: "#00A3E0" },
{ id: "budget", label: "Budget", icon: "account_balance", color: "#10B981" },
{ id: "sponsors", label: "Sponsors", icon: "handshake", color: "#F59E0B" },
] as const;
function toggleModule(id: ModuleType) {
if (deptModules.includes(id)) {
deptModules = deptModules.filter((m) => m !== id);
} else {
deptModules = [...deptModules, id];
}
}
let showRoleModal = $state(false);
let editingRole = $state<EventRole | null>(null);
let roleName = $state("");
@@ -132,6 +156,53 @@
"#F97316", "#3B82F6",
];
const deptPresets: { name: string; color: string; modules: ModuleType[] }[] = [
{ name: "Logistics", color: "#F59E0B", modules: ["kanban", "files", "checklist"] },
{ name: "IT & Tech", color: "#6366F1", modules: ["kanban", "files", "checklist", "notes"] },
{ name: "Marketing", color: "#EC4899", modules: ["kanban", "files", "notes"] },
{ name: "Finance", color: "#10B981", modules: ["kanban", "files", "checklist", "budget"] },
{ name: "Program", color: "#8B5CF6", modules: ["kanban", "files", "schedule", "notes"] },
{ name: "Sponsorship", color: "#00A3E0", modules: ["kanban", "files", "contacts", "notes", "sponsors"] },
{ name: "Design", color: "#F97316", modules: ["kanban", "files"] },
{ name: "Volunteers", color: "#14B8A6", modules: ["kanban", "files", "checklist", "schedule"] },
{ name: "Venue Management", color: "#3B82F6", modules: ["kanban", "files", "checklist"] },
{ name: "Security", color: "#EF4444", modules: ["kanban", "checklist"] },
{ name: "Bar / Catering", color: "#F59E0B", modules: ["kanban", "files", "checklist"] },
{ name: "Photography", color: "#8B5CF6", modules: ["kanban", "files"] },
{ name: "Registration", color: "#10B981", modules: ["kanban", "checklist"] },
{ name: "Ticket Sales", color: "#EC4899", modules: ["kanban", "files", "checklist"] },
];
const rolePresets: { name: string; color: string }[] = [
{ name: "Head Organizer", color: "#EF4444" },
{ name: "Team Lead", color: "#8B5CF6" },
{ name: "Organizer", color: "#F59E0B" },
{ name: "Volunteer", color: "#10B981" },
{ name: "Sponsor", color: "#00A3E0" },
{ name: "Coordinator", color: "#6366F1" },
{ name: "Designer", color: "#F97316" },
{ name: "Technician", color: "#3B82F6" },
];
// Filter out presets that already exist
const availableDeptPresets = $derived(
deptPresets.filter((p) => !departments.some((d) => d.name === p.name)),
);
const availableRolePresets = $derived(
rolePresets.filter((p) => !roles.some((r) => r.name === p.name)),
);
function autofillDept(preset: { name: string; color: string; modules: ModuleType[] }) {
deptName = preset.name;
deptColor = preset.color;
deptModules = [...preset.modules];
}
function autofillRole(preset: { name: string; color: string }) {
roleName = preset.name;
roleColor = preset.color;
}
function getMemberName(member: EventMemberWithDetails): string {
return member.profile?.full_name || member.profile?.email || "Unknown";
}
@@ -290,6 +361,7 @@
editingDept = dept ?? null;
deptName = dept?.name ?? "";
deptColor = dept?.color ?? "#00A3E0";
deptModules = ["kanban", "files", "checklist"];
showDeptModal = true;
}
@@ -310,13 +382,14 @@
);
toasts.success(m.team_dept_updated());
} else {
const { data: created, error } = await supabase
const { data: created, error } = await (supabase as any)
.from("event_departments")
.insert({
event_id: data.event.id,
name: deptName.trim(),
color: deptColor,
sort_order: departments.length,
enabled_modules: deptModules,
})
.select()
.single();
@@ -795,8 +868,25 @@
</Modal>
<!-- Department Modal -->
<Modal isOpen={showDeptModal} onClose={() => (showDeptModal = false)} title={editingDept ? m.team_edit_department() : m.team_add_department()}>
<Modal isOpen={showDeptModal} onClose={() => (showDeptModal = false)} title={editingDept ? m.team_edit_department() : m.team_add_department()} size="lg">
<div class="flex flex-col gap-4">
{#if !editingDept && availableDeptPresets.length > 0}
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body">Quick add</span>
<div class="flex flex-wrap gap-1.5">
{#each availableDeptPresets as preset}
<button
type="button"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-light/10 hover:border-light/30 transition-all text-[12px] text-light/50 hover:text-white"
onclick={() => autofillDept(preset)}
>
<span class="w-2 h-2 rounded-full" style="background-color: {preset.color}"></span>
{preset.name}
</button>
{/each}
</div>
</div>
{/if}
<div class="flex flex-col gap-1.5">
<label for="dept-name" class="text-body-sm text-light/60 font-body">{m.team_dept_name()}</label>
<input id="dept-name" type="text" bind:value={deptName} placeholder={m.team_dept_name()} class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary" />
@@ -805,10 +895,30 @@
<span class="text-body-sm text-light/60 font-body">Color</span>
<div class="flex items-center gap-2">
{#each presetColors as c}
<button type="button" class="w-6 h-6 rounded-full border-2 transition-all {deptColor === c ? 'border-white scale-110' : 'border-transparent hover:border-light/30'}" style="background-color: {c}" onclick={() => (deptColor = c)}></button>
<button type="button" class="w-6 h-6 rounded-full border-2 transition-all {deptColor === c ? 'border-white scale-110' : 'border-transparent hover:border-light/30'}" style="background-color: {c}" onclick={() => (deptColor = c)} aria-label="Color {c}"></button>
{/each}
</div>
</div>
{#if !editingDept}
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body">Modules</span>
<div class="grid grid-cols-3 gap-2">
{#each allModules as mod}
<button
type="button"
class="flex items-center gap-2 px-3 py-2 rounded-xl border transition-all text-left {deptModules.includes(mod.id) ? 'border-primary/50 bg-primary/10' : 'border-light/10 hover:border-light/20'}"
onclick={() => toggleModule(mod.id)}
>
<span
class="material-symbols-rounded"
style="font-size: 16px; color: {deptModules.includes(mod.id) ? mod.color : 'rgba(255,255,255,0.3)'}; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>{mod.icon}</span>
<span class="text-[12px] {deptModules.includes(mod.id) ? 'text-white' : 'text-light/40'}">{mod.label}</span>
</button>
{/each}
</div>
</div>
{/if}
{#if editingDept}
<button type="button" class="text-[11px] text-error hover:underline self-start" onclick={() => { handleDeleteDept(editingDept!); showDeptModal = false; }}>
{m.team_dept_delete_confirm({ name: editingDept.name })}
@@ -826,6 +936,23 @@
<!-- Role Modal -->
<Modal isOpen={showRoleModal} onClose={() => (showRoleModal = false)} title={editingRole ? m.team_edit_role() : m.team_add_role()}>
<div class="flex flex-col gap-4">
{#if !editingRole && availableRolePresets.length > 0}
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body">Quick add</span>
<div class="flex flex-wrap gap-1.5">
{#each availableRolePresets as preset}
<button
type="button"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-light/10 hover:border-light/30 transition-all text-[12px] text-light/50 hover:text-white"
onclick={() => autofillRole(preset)}
>
<span class="w-2 h-2 rounded-full" style="background-color: {preset.color}"></span>
{preset.name}
</button>
{/each}
</div>
</div>
{/if}
<div class="flex flex-col gap-1.5">
<label for="role-name" class="text-body-sm text-light/60 font-body">{m.team_role_name()}</label>
<input id="role-name" type="text" bind:value={roleName} placeholder={m.team_role_name()} class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary" />
@@ -834,7 +961,7 @@
<span class="text-body-sm text-light/60 font-body">Color</span>
<div class="flex items-center gap-2">
{#each presetColors as c}
<button type="button" class="w-6 h-6 rounded-full border-2 transition-all {roleColor === c ? 'border-white scale-110' : 'border-transparent hover:border-light/30'}" style="background-color: {c}" onclick={() => (roleColor = c)}></button>
<button type="button" class="w-6 h-6 rounded-full border-2 transition-all {roleColor === c ? 'border-white scale-110' : 'border-transparent hover:border-light/30'}" style="background-color: {c}" onclick={() => (roleColor = c)} aria-label="Color {c}"></button>
{/each}
</div>
</div>

View File

@@ -56,6 +56,7 @@
const supabase = getContext<SupabaseClient<Database>>("supabase");
const log = createLogger("page.kanban");
// svelte-ignore state_referenced_locally
let boards = $state(data.boards);
$effect(() => {
boards = data.boards;
@@ -543,6 +544,7 @@
<!-- Board toolbar -->
<div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0">
{#if isRenamingBoard && selectedBoard}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
class="flex-1 bg-dark border border-primary rounded-lg px-3 py-1 text-white font-heading text-body focus:outline-none"

View File

@@ -102,9 +102,13 @@
];
// Shared state passed to child components
// svelte-ignore state_referenced_locally
let members = $state<Member[]>(data.members as Member[]);
// svelte-ignore state_referenced_locally
let roles = $state<OrgRole[]>(data.roles as OrgRole[]);
// svelte-ignore state_referenced_locally
let invites = $state<Invite[]>(data.invites as Invite[]);
// svelte-ignore state_referenced_locally
let orgCalendar = $state<OrgCalendar | null>(
data.orgCalendar as OrgCalendar | null,
);
@@ -415,57 +419,66 @@
onClose={() => (showCreateTagModal = false)}
title={editingTag ? "Edit Tag" : "Create Tag"}
>
<div class="space-y-4">
<Input
label="Name"
bind:value={tagName}
placeholder={m.settings_tags_name_placeholder()}
/>
<div>
<span class="block text-sm font-medium text-light mb-2">Color</span>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label for="tag-name" class="text-body-sm text-light/60 font-body">{m.settings_tags_name_placeholder()}</label>
<input
id="tag-name"
type="text"
bind:value={tagName}
placeholder={m.settings_tags_name_placeholder()}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body">Color</span>
<div class="flex flex-wrap gap-2">
{#each TAG_COLORS as color}
<button
type="button"
class="w-8 h-8 rounded-full transition-transform {tagColor ===
color
? 'ring-2 ring-white scale-110'
: ''}"
class="w-6 h-6 rounded-full border-2 transition-all {tagColor === color
? 'border-white scale-110'
: 'border-transparent hover:border-light/30'}"
style="background-color: {color}"
onclick={() => (tagColor = color)}
aria-label="Color {color}"
></button>
{/each}
</div>
<div class="flex items-center gap-2 mt-3">
<span class="text-xs text-light/40">Custom:</span>
<input
type="color"
class="w-8 h-8 rounded cursor-pointer border-0 bg-transparent"
bind:value={tagColor}
/>
<span class="text-xs text-light/50">{tagColor}</span>
<label
class="w-6 h-6 rounded-full border-2 border-dashed border-light/20 hover:border-light/40 transition-all cursor-pointer flex items-center justify-center overflow-hidden"
title="Custom color"
>
<input
type="color"
class="opacity-0 absolute w-0 h-0"
bind:value={tagColor}
/>
<span
class="material-symbols-rounded text-light/30"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>colorize</span>
</label>
</div>
</div>
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg">
<span class="text-sm text-light/50">Preview:</span>
<div class="flex items-center gap-3 p-3 bg-dark/50 rounded-xl">
<span class="text-body-sm text-light/40">Preview:</span>
<span
class="rounded-[4px] px-2 py-1 font-body font-bold text-[13px] text-night leading-none"
class="rounded-lg px-2.5 py-1 font-body font-bold text-[12px] text-night leading-none"
style="background-color: {tagColor}"
>
{tagName || "Tag name"}
</span>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
onclick={() => (showCreateTagModal = false)}>Cancel</Button
>
<Button
<div class="flex items-center justify-end gap-3 pt-2 border-t border-light/5">
<button type="button" class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (showCreateTagModal = false)}>{m.btn_cancel()}</button>
<button
type="button"
disabled={!tagName.trim() || isSavingTag}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={saveTag}
loading={isSavingTag}
disabled={!tagName.trim()}
>{editingTag ? "Save" : "Create"}</Button
>
{isSavingTag ? "..." : editingTag ? m.btn_save() : m.btn_create()}
</button>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,104 @@
import { error, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// Cast helper for columns not yet in generated types
function db(supabase: any) {
return supabase as any;
}
export const load: PageServerLoad = async ({ locals }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
redirect(303, '/login');
}
// Check platform admin status
const { data: profile } = await db(locals.supabase)
.from('profiles')
.select('is_platform_admin')
.eq('id', user.id)
.single();
if (!profile?.is_platform_admin) {
error(403, 'Access denied. Platform admin only.');
}
// Fetch all platform data in parallel
const [
orgsResult,
profilesResult,
eventsResult,
orgMembersResult,
] = await Promise.all([
db(locals.supabase)
.from('organizations')
.select('*')
.order('created_at', { ascending: false }),
db(locals.supabase)
.from('profiles')
.select('id, email, full_name, avatar_url, is_platform_admin, created_at')
.order('created_at', { ascending: false }),
db(locals.supabase)
.from('events')
.select('id, name, slug, status, start_date, end_date, org_id, created_at')
.order('created_at', { ascending: false }),
db(locals.supabase)
.from('org_members')
.select('id, user_id, org_id, role')
.order('created_at', { ascending: false }),
]);
const organizations = orgsResult.data ?? [];
const profiles = profilesResult.data ?? [];
const events = eventsResult.data ?? [];
const orgMembers = orgMembersResult.data ?? [];
// Compute stats
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const newUsersLast30d = profiles.filter(
(p: any) => p.created_at && new Date(p.created_at) > thirtyDaysAgo
).length;
const newUsersLast7d = profiles.filter(
(p: any) => p.created_at && new Date(p.created_at) > sevenDaysAgo
).length;
const activeEvents = events.filter((e: any) => e.status === 'active').length;
const planningEvents = events.filter((e: any) => e.status === 'planning').length;
// Org member counts
const orgMemberCounts: Record<string, number> = {};
for (const m of orgMembers) {
orgMemberCounts[m.org_id] = (orgMemberCounts[m.org_id] || 0) + 1;
}
// Org event counts
const orgEventCounts: Record<string, number> = {};
for (const e of events) {
if (e.org_id) {
orgEventCounts[e.org_id] = (orgEventCounts[e.org_id] || 0) + 1;
}
}
return {
organizations: organizations.map((o: any) => ({
...o,
memberCount: orgMemberCounts[o.id] || 0,
eventCount: orgEventCounts[o.id] || 0,
})),
profiles,
events,
stats: {
totalUsers: profiles.length,
totalOrgs: organizations.length,
totalEvents: events.length,
totalMemberships: orgMembers.length,
newUsersLast30d,
newUsersLast7d,
activeEvents,
planningEvents,
},
};
};

View File

@@ -0,0 +1,406 @@
<script lang="ts">
import { Button, Badge, Avatar, Card, StatCard, TabBar, Input } from "$lib/components/ui";
let { data } = $props();
let activeTab = $state("overview");
let orgSearch = $state("");
let userSearch = $state("");
let eventSearch = $state("");
const filteredOrgs = $derived(
orgSearch
? data.organizations.filter((o: any) =>
o.name?.toLowerCase().includes(orgSearch.toLowerCase()) ||
o.slug?.toLowerCase().includes(orgSearch.toLowerCase())
)
: data.organizations,
);
const filteredUsers = $derived(
userSearch
? data.profiles.filter((p: any) =>
p.email?.toLowerCase().includes(userSearch.toLowerCase()) ||
p.full_name?.toLowerCase().includes(userSearch.toLowerCase())
)
: data.profiles,
);
const filteredEvents = $derived(
eventSearch
? data.events.filter((e: any) =>
e.name?.toLowerCase().includes(eventSearch.toLowerCase()) ||
e.slug?.toLowerCase().includes(eventSearch.toLowerCase())
)
: data.events,
);
function formatDate(dateStr: string | null) {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function timeAgo(dateStr: string | null) {
if (!dateStr) return "—";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return formatDate(dateStr);
}
const statusColors: Record<string, string> = {
planning: "text-amber-400 bg-amber-400/10",
active: "text-emerald-400 bg-emerald-400/10",
completed: "text-blue-400 bg-blue-400/10",
archived: "text-light/40 bg-light/5",
draft: "text-light/40 bg-light/5",
};
// Find org name by id
const orgMap = $derived(
Object.fromEntries(data.organizations.map((o: any) => [o.id, o])),
);
</script>
<svelte:head>
<title>Platform Admin | Root</title>
</svelte:head>
<div class="min-h-screen bg-background">
<!-- Header -->
<header class="border-b border-light/5 bg-dark/30">
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/"
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
>
<span class="material-symbols-rounded" style="font-size: 20px;">arrow_back</span>
</a>
<div class="flex items-center gap-2">
<span
class="material-symbols-rounded text-primary"
style="font-size: 24px; font-variation-settings: 'FILL' 1;"
>admin_panel_settings</span
>
<span class="font-heading text-body text-white">Platform Admin</span>
</div>
<Badge variant="error" size="sm">Admin Only</Badge>
</div>
<div class="flex items-center gap-2 text-light/40 text-body-sm">
<span class="material-symbols-rounded" style="font-size: 16px;">schedule</span>
{new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
</div>
</div>
</header>
<div class="max-w-7xl mx-auto px-6 py-6 space-y-6">
<!-- Stats Overview -->
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3">
<StatCard label="Total Users" value={data.stats.totalUsers} icon="group" />
<StatCard label="Organizations" value={data.stats.totalOrgs} icon="business" />
<StatCard label="Total Events" value={data.stats.totalEvents} icon="event" />
<StatCard label="Memberships" value={data.stats.totalMemberships} icon="badge" />
<StatCard label="New (7d)" value={data.stats.newUsersLast7d} icon="person_add" />
<StatCard label="New (30d)" value={data.stats.newUsersLast30d} icon="trending_up" />
<StatCard label="Active Events" value={data.stats.activeEvents} icon="play_circle" />
<StatCard label="Planning" value={data.stats.planningEvents} icon="edit_calendar" />
</div>
<!-- Tab Navigation -->
<TabBar
tabs={[
{ value: "overview", label: "Overview", icon: "dashboard" },
{ value: "organizations", label: "Organizations", icon: "business" },
{ value: "users", label: "Users", icon: "group" },
{ value: "events", label: "Events", icon: "event" },
]}
active={activeTab}
onchange={(v) => (activeTab = v)}
/>
<!-- Tab Content -->
{#if activeTab === "overview"}
<div class="grid lg:grid-cols-2 gap-6">
<!-- Recent Organizations -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="font-heading text-body text-white">Recent Organizations</h3>
<button
type="button"
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
onclick={() => (activeTab = "organizations")}
>
View all →
</button>
</div>
<div class="space-y-2">
{#each data.organizations.slice(0, 5) as org}
<div class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-dark/50 transition-colors">
<div class="flex items-center gap-3 min-w-0">
<Avatar name={org.name ?? "Org"} size="sm" />
<div class="min-w-0">
<p class="text-body-sm text-white truncate">{org.name}</p>
<p class="text-[10px] text-light/30">/{org.slug}</p>
</div>
</div>
<div class="flex items-center gap-3 shrink-0 text-[10px] text-light/40">
<span>{org.memberCount} members</span>
<span>{org.eventCount} events</span>
</div>
</div>
{/each}
{#if data.organizations.length === 0}
<p class="text-body-sm text-light/30 text-center py-4">No organizations yet</p>
{/if}
</div>
</div>
<!-- Recent Users -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="font-heading text-body text-white">Recent Users</h3>
<button
type="button"
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
onclick={() => (activeTab = "users")}
>
View all →
</button>
</div>
<div class="space-y-2">
{#each data.profiles.slice(0, 5) as profile}
<div class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-dark/50 transition-colors">
<div class="flex items-center gap-3 min-w-0">
<Avatar name={profile.full_name ?? profile.email} size="sm" src={profile.avatar_url} />
<div class="min-w-0">
<p class="text-body-sm text-white truncate">{profile.full_name ?? "No name"}</p>
<p class="text-[10px] text-light/30">{profile.email}</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
{#if profile.is_platform_admin}
<Badge variant="error" size="sm">Admin</Badge>
{/if}
<span class="text-[10px] text-light/30">{timeAgo(profile.created_at)}</span>
</div>
</div>
{/each}
{#if data.profiles.length === 0}
<p class="text-body-sm text-light/30 text-center py-4">No users yet</p>
{/if}
</div>
</div>
<!-- Recent Events -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<h3 class="font-heading text-body text-white">Recent Events</h3>
<button
type="button"
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
onclick={() => (activeTab = "events")}
>
View all →
</button>
</div>
{#if data.events.length > 0}
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr class="border-b border-light/5">
<th class="text-[10px] text-light/40 font-body pb-2 pr-4">Event</th>
<th class="text-[10px] text-light/40 font-body pb-2 pr-4">Organization</th>
<th class="text-[10px] text-light/40 font-body pb-2 pr-4">Status</th>
<th class="text-[10px] text-light/40 font-body pb-2 pr-4">Dates</th>
<th class="text-[10px] text-light/40 font-body pb-2">Created</th>
</tr>
</thead>
<tbody>
{#each data.events.slice(0, 8) as event}
<tr class="border-b border-light/5 last:border-0 hover:bg-dark/30">
<td class="py-2.5 pr-4">
<p class="text-body-sm text-white">{event.name}</p>
<p class="text-[10px] text-light/30">/{event.slug}</p>
</td>
<td class="py-2.5 pr-4 text-body-sm text-light/50">
{orgMap[event.org_id]?.name ?? "—"}
</td>
<td class="py-2.5 pr-4">
<span class="text-[10px] px-2 py-0.5 rounded-full capitalize {statusColors[event.status] ?? 'text-light/40 bg-light/5'}">
{event.status}
</span>
</td>
<td class="py-2.5 pr-4 text-[11px] text-light/40">
{formatDate(event.start_date)}{formatDate(event.end_date)}
</td>
<td class="py-2.5 text-[11px] text-light/30">{timeAgo(event.created_at)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-body-sm text-light/30 text-center py-4">No events yet</p>
{/if}
</div>
</div>
{:else if activeTab === "organizations"}
<div class="space-y-4">
<div class="max-w-sm">
<Input placeholder="Search organizations..." icon="search" bind:value={orgSearch} />
</div>
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden">
<table class="w-full text-left">
<thead>
<tr class="border-b border-light/5 bg-dark/20">
<th class="text-[10px] text-light/40 font-body py-3 px-4">Organization</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Slug</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Members</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Events</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Created</th>
</tr>
</thead>
<tbody>
{#each filteredOrgs as org}
<tr class="border-b border-light/5 last:border-0 hover:bg-dark/30 transition-colors">
<td class="py-3 px-4">
<div class="flex items-center gap-3">
<Avatar name={org.name ?? "Org"} size="sm" />
<span class="text-body-sm text-white">{org.name}</span>
</div>
</td>
<td class="py-3 px-4 text-body-sm text-light/40">/{org.slug}</td>
<td class="py-3 px-4">
<Badge variant="default" size="sm">{org.memberCount}</Badge>
</td>
<td class="py-3 px-4">
<Badge variant="primary" size="sm">{org.eventCount}</Badge>
</td>
<td class="py-3 px-4 text-[11px] text-light/30">{formatDate(org.created_at)}</td>
</tr>
{/each}
{#if filteredOrgs.length === 0}
<tr>
<td colspan="5" class="py-8 text-center text-body-sm text-light/30">
{orgSearch ? "No organizations match your search" : "No organizations yet"}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
<p class="text-[10px] text-light/30">{filteredOrgs.length} of {data.organizations.length} organizations</p>
</div>
{:else if activeTab === "users"}
<div class="space-y-4">
<div class="max-w-sm">
<Input placeholder="Search users..." icon="search" bind:value={userSearch} />
</div>
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden">
<table class="w-full text-left">
<thead>
<tr class="border-b border-light/5 bg-dark/20">
<th class="text-[10px] text-light/40 font-body py-3 px-4">User</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Email</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Role</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Joined</th>
</tr>
</thead>
<tbody>
{#each filteredUsers as profile}
<tr class="border-b border-light/5 last:border-0 hover:bg-dark/30 transition-colors">
<td class="py-3 px-4">
<div class="flex items-center gap-3">
<Avatar name={profile.full_name ?? profile.email} size="sm" src={profile.avatar_url} />
<span class="text-body-sm text-white">{profile.full_name ?? "No name"}</span>
</div>
</td>
<td class="py-3 px-4 text-body-sm text-light/40">{profile.email}</td>
<td class="py-3 px-4">
{#if profile.is_platform_admin}
<Badge variant="error" size="sm">Platform Admin</Badge>
{:else}
<Badge variant="default" size="sm">User</Badge>
{/if}
</td>
<td class="py-3 px-4 text-[11px] text-light/30">{formatDate(profile.created_at)}</td>
</tr>
{/each}
{#if filteredUsers.length === 0}
<tr>
<td colspan="4" class="py-8 text-center text-body-sm text-light/30">
{userSearch ? "No users match your search" : "No users yet"}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
<p class="text-[10px] text-light/30">{filteredUsers.length} of {data.profiles.length} users</p>
</div>
{:else if activeTab === "events"}
<div class="space-y-4">
<div class="max-w-sm">
<Input placeholder="Search events..." icon="search" bind:value={eventSearch} />
</div>
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden">
<table class="w-full text-left">
<thead>
<tr class="border-b border-light/5 bg-dark/20">
<th class="text-[10px] text-light/40 font-body py-3 px-4">Event</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Organization</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Status</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Start</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">End</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Created</th>
</tr>
</thead>
<tbody>
{#each filteredEvents as event}
<tr class="border-b border-light/5 last:border-0 hover:bg-dark/30 transition-colors">
<td class="py-3 px-4">
<div>
<p class="text-body-sm text-white">{event.name}</p>
<p class="text-[10px] text-light/30">/{event.slug}</p>
</div>
</td>
<td class="py-3 px-4 text-body-sm text-light/40">
{orgMap[event.org_id]?.name ?? "—"}
</td>
<td class="py-3 px-4">
<span class="text-[10px] px-2 py-0.5 rounded-full capitalize {statusColors[event.status] ?? 'text-light/40 bg-light/5'}">
{event.status}
</span>
</td>
<td class="py-3 px-4 text-[11px] text-light/40">{formatDate(event.start_date)}</td>
<td class="py-3 px-4 text-[11px] text-light/40">{formatDate(event.end_date)}</td>
<td class="py-3 px-4 text-[11px] text-light/30">{timeAgo(event.created_at)}</td>
</tr>
{/each}
{#if filteredEvents.length === 0}
<tr>
<td colspan="6" class="py-8 text-center text-body-sm text-light/30">
{eventSearch ? "No events match your search" : "No events yet"}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
<p class="text-[10px] text-light/30">{filteredEvents.length} of {data.events.length} events</p>
</div>
{/if}
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { Button, Card } from "$lib/components/ui";
import { Button } from "$lib/components/ui";
import { createLogger } from "$lib/utils/logger";
import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
@@ -24,6 +24,7 @@
const log = createLogger("page.invite");
let isAccepting = $state(false);
// svelte-ignore state_referenced_locally
let error = $state(data.error || "");
async function acceptInvite() {
@@ -94,82 +95,66 @@
}
</script>
<div class="min-h-screen bg-dark flex items-center justify-center p-4">
<div
class="w-full max-w-md bg-dark-light rounded-xl border border-light/10"
>
<div class="p-6 text-center">
<div class="min-h-screen bg-background flex items-center justify-center p-4">
<div class="w-full max-w-sm">
<div class="bg-surface rounded-2xl border border-light/5 p-6 text-center">
{#if data.error}
<!-- Invalid/Expired Invite -->
<div
class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-500/20 flex items-center justify-center"
class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-error/10 flex items-center justify-center"
>
<svg
class="w-8 h-8 text-red-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span
class="material-symbols-rounded text-error"
style="font-size: 28px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 28;"
>error</span>
</div>
<h1 class="text-xl font-bold text-light mb-2">
<h1 class="text-body font-heading text-white mb-2">
Invalid Invite
</h1>
<p class="text-light/60 mb-6">{data.error}</p>
<p class="text-body-sm text-light/40 mb-6">{data.error}</p>
<Button onclick={() => goto("/")}>Go Home</Button>
{:else if data.invite}
<!-- Valid Invite -->
<div
class="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/20 flex items-center justify-center"
class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-primary/10 flex items-center justify-center"
>
<svg
class="w-8 h-8 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span
class="material-symbols-rounded text-primary"
style="font-size: 28px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 28;"
>group_add</span>
</div>
<h1 class="text-xl font-bold text-light mb-2">
<h1 class="text-body font-heading text-white mb-2">
You're Invited!
</h1>
<p class="text-light/60 mb-1">You've been invited to join</p>
<p class="text-2xl font-bold text-primary mb-1">
<p class="text-body-sm text-light/40 mb-1">You've been invited to join</p>
<p class="text-heading-sm font-heading text-primary mb-1">
{data.invite.org.name}
</p>
<p class="text-light/50 text-sm mb-6">as {data.invite.role}</p>
<p class="text-body-sm text-light/30 mb-6">as <span class="text-light/60 capitalize">{data.invite.role}</span></p>
{#if error}
<div
class="p-3 mb-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"
class="p-3 mb-4 bg-error/10 border border-error/20 rounded-xl text-error text-body-sm flex items-center gap-2 text-left"
>
<span class="material-symbols-rounded shrink-0" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">error</span>
{error}
</div>
{/if}
{#if data.user}
<!-- User is logged in -->
<p class="text-light/60 text-sm mb-4">
Signed in as <strong class="text-light"
<p class="text-body-sm text-light/40 mb-4">
Signed in as <strong class="text-white"
>{data.user.email}</strong
>
</p>
<div class="w-full">
<Button onclick={acceptInvite} loading={isAccepting}>
<Button fullWidth onclick={acceptInvite} loading={isAccepting}>
Accept Invite & Join
</Button>
</div>
<p class="text-light/40 text-xs mt-3">
<p class="text-light/30 text-[11px] mt-3">
Wrong account? <a
href="/auth/logout"
class="text-primary hover:underline">Sign out</a
@@ -177,12 +162,12 @@
</p>
{:else}
<!-- User not logged in -->
<p class="text-light/60 text-sm mb-4">
<p class="text-body-sm text-light/40 mb-4">
Sign in or create an account to accept this invite.
</p>
<div class="flex flex-col gap-2">
<Button onclick={goToLogin}>Sign In</Button>
<Button onclick={goToSignup} variant="tertiary"
<Button fullWidth onclick={goToLogin}>Sign In</Button>
<Button fullWidth onclick={goToSignup} variant="secondary"
>Create Account</Button
>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Button, Input, Card } from "$lib/components/ui";
import { Button, Input } from "$lib/components/ui";
import { createClient } from "$lib/supabase";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
@@ -91,30 +91,33 @@
<title>{mode === "login" ? "Log In" : "Sign Up"} | Root</title>
</svelte:head>
<div class="min-h-screen bg-dark flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="min-h-screen bg-background flex items-center justify-center p-4">
<div class="w-full max-w-sm">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-primary mb-2">{m.app_name()}</h1>
<p class="text-light/60">{m.login_subtitle()}</p>
<div class="w-12 h-12 mx-auto mb-4 bg-primary/10 rounded-2xl flex items-center justify-center">
<span class="material-symbols-rounded text-primary" style="font-size: 24px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;">hub</span>
</div>
<h1 class="text-heading-sm font-heading text-white mb-1">{m.app_name()}</h1>
<p class="text-body-sm text-light/40">{m.login_subtitle()}</p>
</div>
<Card variant="elevated" padding="lg">
<div class="bg-surface rounded-2xl border border-light/5 p-6">
{#if signupSuccess}
<div class="text-center py-4">
<div
class="w-16 h-16 mx-auto mb-4 rounded-full bg-success/20 flex items-center justify-center"
class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-emerald-500/10 flex items-center justify-center"
>
<span
class="material-symbols-rounded text-success"
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 32;"
class="material-symbols-rounded text-emerald-400"
style="font-size: 28px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 28;"
>
mark_email_read
</span>
</div>
<h2 class="text-xl font-semibold text-light mb-2">
<h2 class="text-body font-heading text-white mb-2">
{m.login_signup_success_title()}
</h2>
<p class="text-light/60 text-sm mb-4">
<p class="text-body-sm text-light/40 mb-6">
{m.login_signup_success_text({ email })}
</p>
<Button
@@ -128,16 +131,27 @@
</Button>
</div>
{:else}
<h2 class="text-xl font-semibold text-light mb-6">
{mode === "login"
? m.login_tab_login()
: m.login_tab_signup()}
</h2>
<!-- Tab switcher -->
<div class="flex items-center gap-1 bg-dark/50 rounded-xl p-1 mb-6">
<button
class="flex-1 py-2 rounded-lg text-body-sm font-body transition-colors {mode === 'login' ? 'bg-primary text-background' : 'text-light/40 hover:text-white'}"
onclick={() => (mode = "login")}
>
{m.login_tab_login()}
</button>
<button
class="flex-1 py-2 rounded-lg text-body-sm font-body transition-colors {mode === 'signup' ? 'bg-primary text-background' : 'text-light/40 hover:text-white'}"
onclick={() => (mode = "signup")}
>
{m.login_tab_signup()}
</button>
</div>
{#if error}
<div
class="mb-4 p-3 bg-error/20 border border-error/30 rounded-xl text-error text-sm"
class="mb-4 p-3 bg-error/10 border border-error/20 rounded-xl text-error text-body-sm flex items-center gap-2"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">error</span>
{error}
</div>
{/if}
@@ -147,7 +161,7 @@
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
class="flex flex-col gap-4"
>
<Input
type="email"
@@ -172,60 +186,39 @@
</Button>
</form>
<div class="my-6 flex items-center gap-3">
<div class="my-5 flex items-center gap-3">
<div class="flex-1 h-px bg-light/10"></div>
<span class="text-light/40 text-sm"
<span class="text-light/30 text-[11px] uppercase tracking-wider"
>{m.login_or_continue()}</span
>
<div class="flex-1 h-px bg-light/10"></div>
</div>
<Button
variant="secondary"
fullWidth
<button
class="w-full flex items-center justify-center gap-2.5 px-4 py-2.5 rounded-xl border border-light/10 hover:border-light/20 hover:bg-light/5 transition-all text-body-sm text-white"
onclick={() => handleOAuth("google")}
>
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24">
<svg class="w-4 h-4" viewBox="0 0 24 24">
<path
fill="currentColor"
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
fill="#EA4335"
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"
/>
</svg>
{m.login_google()}
</Button>
<p class="mt-6 text-center text-light/60 text-sm">
{#if mode === "login"}
{m.login_signup_prompt()}
<button
class="text-primary hover:underline"
onclick={() => (mode = "signup")}
>
{m.login_tab_signup()}
</button>
{:else}
{m.login_login_prompt()}
<button
class="text-primary hover:underline"
onclick={() => (mode = "login")}
>
{m.login_tab_login()}
</button>
{/if}
</p>
</button>
{/if}
</Card>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff