Mega push vol 7 mvp lesgoooo
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let documents = $state(data.documents);
|
||||
$effect(() => {
|
||||
documents = data.documents;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let documents = $state(data.documents);
|
||||
$effect(() => {
|
||||
documents = data.documents;
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
1092
src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.svelte
Normal file
1092
src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
104
src/routes/admin/+page.server.ts
Normal file
104
src/routes/admin/+page.server.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
406
src/routes/admin/+page.svelte
Normal file
406
src/routes/admin/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user