Mega push vol 5, working on messaging now

This commit is contained in:
AlacrisDevs
2026-02-07 01:31:55 +02:00
parent d8bbfd9dc3
commit e55881b38b
77 changed files with 8478 additions and 1554 deletions

View File

@@ -1,4 +1,6 @@
<script lang="ts">
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";
@@ -6,11 +8,21 @@
import { ToastContainer } from "$lib/components/ui";
let { children, data } = $props();
const supabase = createClient();
setContext("supabase", supabase);
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}
<ToastContainer />
<div style="display:none">
{#each locales as locale}
<a
href={localizeHref(page.url.pathname, { locale })}
>
{locale}
</a>
{/each}
</div>

View File

@@ -19,27 +19,17 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
error(404, 'Organization not found');
}
// Now fetch membership, members, and activity in parallel (all depend on org.id)
const [membershipResult, membersResult, activityResult] = await Promise.all([
// Now fetch membership, members, activity, and user profile in parallel (all depend on org.id)
const [membershipResult, membersResult, activityResult, profileResult, docCountResult, folderCountResult, kanbanCountResult] = await Promise.all([
locals.supabase
.from('org_members')
.select('role')
.select('role, role_id')
.eq('org_id', org.id)
.eq('user_id', user.id)
.single(),
locals.supabase
.from('org_members')
.select(`
id,
user_id,
role,
profiles:user_id (
id,
email,
full_name,
avatar_url
)
`)
.select('id, user_id, role')
.eq('org_id', org.id)
.limit(10),
locals.supabase
@@ -58,23 +48,87 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
`)
.eq('org_id', org.id)
.order('created_at', { ascending: false })
.limit(10)
.limit(10),
locals.supabase
.from('profiles')
.select('id, email, full_name, avatar_url')
.eq('id', user.id)
.single(),
locals.supabase
.from('documents')
.select('id', { count: 'exact', head: true })
.eq('org_id', org.id)
.eq('type', 'document'),
locals.supabase
.from('documents')
.select('id', { count: 'exact', head: true })
.eq('org_id', org.id)
.eq('type', 'folder'),
locals.supabase
.from('documents')
.select('id', { count: 'exact', head: true })
.eq('org_id', org.id)
.eq('type', 'kanban')
]);
const { data: membership } = membershipResult;
const { data: members } = membersResult;
const { data: rawMembers } = membersResult;
const { data: recentActivity } = activityResult;
const { data: profile } = profileResult;
const stats = {
memberCount: (rawMembers ?? []).length,
documentCount: docCountResult.count ?? 0,
folderCount: folderCountResult.count ?? 0,
kanbanCount: kanbanCountResult.count ?? 0,
};
if (!membership) {
error(403, 'You are not a member of this organization');
}
// Resolve user's permissions from their custom org_role (if assigned)
let userPermissions: string[] | null = null;
if (membership.role_id) {
const { data: roleData } = await locals.supabase
.from('org_roles')
.select('permissions')
.eq('id', membership.role_id)
.single();
if (roleData?.permissions && Array.isArray(roleData.permissions)) {
userPermissions = roleData.permissions as string[];
}
}
// Fetch profiles separately since org_members.user_id FK points to auth.users, not profiles
const memberUserIds = (rawMembers ?? []).map(m => m.user_id).filter((id): id is string => id !== null);
let memberProfilesMap: Record<string, { id: string; email: string; full_name: string | null; avatar_url: string | null }> = {};
if (memberUserIds.length > 0) {
const { data: memberProfiles } = await locals.supabase
.from('profiles')
.select('id, email, full_name, avatar_url')
.in('id', memberUserIds);
if (memberProfiles) {
memberProfilesMap = Object.fromEntries(memberProfiles.map(p => [p.id, p]));
}
}
const members = (rawMembers ?? []).map(m => ({
...m,
profiles: (m.user_id ? memberProfilesMap[m.user_id] : null) ?? null
}));
return {
org,
role: membership.role,
userRole: membership.role, // kept for backwards compat — same as role
members: members ?? [],
userRole: membership.role,
userPermissions,
members,
recentActivity: recentActivity ?? [],
user
stats,
user,
profile: profile ?? { id: user.id, email: user.email ?? '', full_name: null, avatar_url: null }
};
};

View File

@@ -1,7 +1,15 @@
<script lang="ts">
import { page, navigating } from "$app/stores";
import { goto } from "$app/navigation";
import type { Snippet } from "svelte";
import { Avatar, Logo } from "$lib/components/ui";
import { getContext } from "svelte";
import { on } from "svelte/events";
import { Avatar, Logo, PageSkeleton } from "$lib/components/ui";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { hasPermission, type Permission } from "$lib/utils/permissions";
import { setContext } from "svelte";
import * as m from "$lib/paraglide/messages";
interface Member {
id: string;
@@ -15,6 +23,13 @@
};
}
interface UserProfile {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
}
interface Props {
data: {
org: {
@@ -23,41 +38,96 @@
slug: string;
avatar_url?: string | null;
};
role: string;
userRole: string;
userPermissions: string[] | null;
members: Member[];
profile: UserProfile;
};
children: Snippet;
}
let { data, children }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
// Provide a permission checker via context so any child component can use it
const canAccess = (permission: Permission | string): boolean =>
hasPermission(data.userRole, data.userPermissions, permission);
setContext("canAccess", canAccess);
// Sidebar collapses on all pages except org overview
const isOrgOverview = $derived($page.url.pathname === `/${data.org.slug}`);
let sidebarHovered = $state(false);
const sidebarCollapsed = $derived(!isOrgOverview && !sidebarHovered);
// User dropdown
let showUserMenu = $state(false);
let menuContainerEl = $state<HTMLElement | null>(null);
// Attach click-outside and Escape listeners only while menu is open.
// Uses svelte/events 'on' to respect Svelte 5 event delegation order.
$effect(() => {
if (!showUserMenu) return;
// Defer so the opening click doesn't immediately close the menu
const timer = setTimeout(() => {
cleanupClick = on(document, "click", (e: MouseEvent) => {
if (
menuContainerEl &&
!menuContainerEl.contains(e.target as Node)
) {
showUserMenu = false;
}
});
}, 0);
const cleanupKey = on(document, "keydown", (e: Event) => {
if ((e as KeyboardEvent).key === "Escape") showUserMenu = false;
});
let cleanupClick: (() => void) | undefined;
return () => {
clearTimeout(timer);
cleanupClick?.();
cleanupKey();
};
});
async function handleLogout() {
await supabase.auth.signOut();
goto("/");
}
const navItems = $derived([
{
href: `/${data.org.slug}/documents`,
label: "Files",
icon: "cloud",
},
{
href: `/${data.org.slug}/calendar`,
label: "Calendar",
icon: "calendar_today",
},
// Only show settings for admins
...(isAdmin
...(canAccess("documents.view")
? [
{
href: `/${data.org.slug}/documents`,
label: m.nav_files(),
icon: "cloud",
},
]
: []),
...(canAccess("calendar.view")
? [
{
href: `/${data.org.slug}/calendar`,
label: m.nav_calendar(),
icon: "calendar_today",
},
]
: []),
// Settings requires settings.view or admin role
...(canAccess("settings.view")
? [
{
href: `/${data.org.slug}/settings`,
label: "Settings",
label: m.nav_settings(),
icon: "settings",
},
]
@@ -110,7 +180,7 @@
<p
class="text-body-sm text-white font-body capitalize whitespace-nowrap"
>
{data.role}
{data.userRole}
</p>
</div>
</a>
@@ -152,10 +222,107 @@
{/each}
</nav>
<!-- Logo at bottom -->
<div class="mt-auto">
<a href="/" title="Back to organizations">
<Logo size={sidebarCollapsed ? "sm" : "md"} />
<!-- User Section + Logo at bottom -->
<div class="mt-auto flex flex-col gap-3">
<!-- User Avatar + Quick Menu -->
<div
class="relative user-menu-container"
bind:this={menuContainerEl}
>
<button
type="button"
class="flex items-center gap-2 p-1 rounded-[32px] hover:bg-dark transition-colors w-full"
onclick={() => (showUserMenu = !showUserMenu)}
aria-expanded={showUserMenu}
aria-haspopup="true"
>
<div
class="shrink-0 transition-all duration-300 {sidebarCollapsed
? 'w-8 h-8'
: 'w-10 h-10'}"
>
<Avatar
name={data.profile.full_name || data.profile.email}
src={data.profile.avatar_url}
size="sm"
/>
</div>
<div
class="min-w-0 flex-1 overflow-hidden text-left transition-all duration-300 {sidebarCollapsed
? 'opacity-0 max-w-0'
: 'opacity-100 max-w-[200px]'}"
>
<p
class="font-body text-body-sm text-white truncate whitespace-nowrap leading-tight"
>
{data.profile.full_name || "User"}
</p>
<p
class="font-body text-[11px] text-light/50 truncate whitespace-nowrap leading-tight"
>
{data.profile.email}
</p>
</div>
</button>
{#if showUserMenu}
<div
class="absolute bottom-full left-0 mb-2 py-1 bg-dark border border-light/10 rounded-xl shadow-xl min-w-[200px] z-50"
>
<a
href="/{data.org.slug}/account"
class="flex items-center gap-3 px-3 py-2 text-sm text-light hover:bg-light/5 transition-colors"
onclick={() => (showUserMenu = false)}
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
person
</span>
<span>{m.user_menu_account_settings()}</span>
</a>
<a
href="/"
class="flex items-center gap-3 px-3 py-2 text-sm text-light hover:bg-light/5 transition-colors"
onclick={() => (showUserMenu = false)}
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
swap_horiz
</span>
<span>{m.user_menu_switch_org()}</span>
</a>
<div class="border-t border-light/10 my-1"></div>
<button
type="button"
class="w-full flex items-center gap-3 px-3 py-2 text-sm text-error hover:bg-error/10 transition-colors"
onclick={handleLogout}
>
<span
class="material-symbols-rounded text-error/60"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
logout
</span>
<span>{m.user_menu_logout()}</span>
</button>
</div>
{/if}
</div>
<!-- Logo -->
<a
href="/"
title="Back to organizations"
class="flex items-center justify-center"
>
<Logo
size={sidebarCollapsed ? "sm" : "md"}
showText={!sidebarCollapsed}
/>
</a>
</div>
</aside>
@@ -163,17 +330,19 @@
<!-- Main Content Area -->
<main class="flex-1 bg-night rounded-[32px] overflow-auto relative">
{#if $navigating}
<div
class="absolute inset-0 z-10 flex items-center justify-center bg-night/80 backdrop-blur-sm"
>
<span
class="material-symbols-rounded text-primary animate-spin"
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 40;"
>
progress_activity
</span>
</div>
{@const target = $navigating.to?.url.pathname ?? ""}
{@const skeletonVariant = target.includes("/kanban")
? "kanban"
: target.includes("/documents")
? "files"
: target.includes("/calendar")
? "calendar"
: target.includes("/settings")
? "settings"
: "default"}
<PageSkeleton variant={skeletonVariant} />
{:else}
{@render children()}
{/if}
{@render children()}
</main>
</div>

View File

@@ -1,21 +1,369 @@
<script lang="ts">
import { Avatar, Card } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface ActivityEntry {
id: string;
action: string;
entity_type: string;
entity_id: string | null;
entity_name: string | null;
created_at: string | null;
profiles: {
full_name: string | null;
email: string | null;
} | null;
}
interface Props {
data: {
org: { id: string; name: string; slug: string };
role: string;
userRole: string;
stats: {
memberCount: number;
documentCount: number;
folderCount: number;
kanbanCount: number;
};
recentActivity: ActivityEntry[];
members: {
id: string;
user_id: string;
role: string;
profiles: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
} | null;
}[];
};
}
let { data }: Props = $props();
const stats = $derived(
data.stats ?? {
memberCount: 0,
documentCount: 0,
folderCount: 0,
kanbanCount: 0,
},
);
const recentActivity = $derived(data.recentActivity ?? []);
const members = $derived(data.members ?? []);
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
const statCards = $derived([
{
label: m.overview_stat_members(),
value: stats.memberCount,
icon: "group",
href: isAdmin ? `/${data.org.slug}/settings` : null,
color: "text-blue-400",
bg: "bg-blue-400/10",
},
{
label: m.overview_stat_documents(),
value: stats.documentCount,
icon: "description",
href: `/${data.org.slug}/documents`,
color: "text-emerald-400",
bg: "bg-emerald-400/10",
},
{
label: m.overview_stat_folders(),
value: stats.folderCount,
icon: "folder",
href: `/${data.org.slug}/documents`,
color: "text-amber-400",
bg: "bg-amber-400/10",
},
{
label: m.overview_stat_boards(),
value: stats.kanbanCount,
icon: "view_kanban",
href: `/${data.org.slug}/documents`,
color: "text-purple-400",
bg: "bg-purple-400/10",
},
]);
const quickLinks = $derived([
{
label: m.nav_files(),
icon: "cloud",
href: `/${data.org.slug}/documents`,
},
{
label: m.nav_calendar(),
icon: "calendar_today",
href: `/${data.org.slug}/calendar`,
},
...(isAdmin
? [
{
label: m.nav_settings(),
icon: "settings",
href: `/${data.org.slug}/settings`,
},
]
: []),
]);
function getEntityTypeLabel(entityType: string): string {
const map: Record<string, () => string> = {
document: m.entity_document,
folder: m.entity_folder,
kanban_board: m.entity_kanban_board,
kanban_card: m.entity_kanban_card,
kanban_column: m.entity_kanban_column,
member: m.entity_member,
role: m.entity_role,
invite: m.entity_invite,
};
return (map[entityType] ?? (() => entityType))();
}
function getActivityIcon(action: string): string {
const map: Record<string, string> = {
create: "add_circle",
update: "edit",
delete: "delete",
move: "drive_file_move",
rename: "edit_note",
};
return map[action] ?? "info";
}
function getActivityColor(action: string): string {
const map: Record<string, string> = {
create: "text-emerald-400",
update: "text-blue-400",
delete: "text-red-400",
move: "text-amber-400",
rename: "text-purple-400",
};
return map[action] ?? "text-light/50";
}
function formatTimeAgo(dateStr: string | null): string {
if (!dateStr) return "";
const now = Date.now();
const then = new Date(dateStr).getTime();
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return m.activity_just_now();
if (diffMin < 60)
return m.activity_minutes_ago({ count: String(diffMin) });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return m.activity_hours_ago({ count: String(diffHr) });
const diffDay = Math.floor(diffHr / 24);
return m.activity_days_ago({ count: String(diffDay) });
}
function getActivityDescription(entry: ActivityEntry): string {
const userName =
entry.profiles?.full_name || entry.profiles?.email || "Someone";
const entityType = getEntityTypeLabel(entry.entity_type);
const name = entry.entity_name ?? "—";
const map: Record<string, () => string> = {
create: () =>
m.activity_created({ user: userName, entityType, name }),
update: () =>
m.activity_updated({ user: userName, entityType, name }),
delete: () =>
m.activity_deleted({ user: userName, entityType, name }),
move: () => m.activity_moved({ user: userName, entityType, name }),
rename: () =>
m.activity_renamed({ user: userName, entityType, name }),
};
return (map[entry.action] ?? map["update"]!)();
}
</script>
<svelte:head>
<title>{data.org.name} | Root</title>
</svelte:head>
<div class="p-4 lg:p-6">
<div class="flex flex-col h-full p-4 lg:p-5 gap-6 overflow-auto">
<!-- Header -->
<header>
<h1 class="text-h1 font-heading text-white">{data.org.name}</h1>
<p class="text-body text-light/60 font-body">Organization Overview</p>
<p class="text-body text-light/60 font-body">{m.overview_title()}</p>
</header>
<!-- Stats Grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
{#each statCards as stat}
{#if stat.href}
<a
href={stat.href}
class="bg-night rounded-2xl p-5 flex flex-col gap-3 hover:bg-night/80 transition-colors group"
>
<div
class="w-10 h-10 rounded-xl {stat.bg} flex items-center justify-center"
>
<span
class="material-symbols-rounded {stat.color}"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{stat.icon}
</span>
</div>
<div>
<p class="text-2xl font-bold text-white">
{stat.value}
</p>
<p class="text-body-sm text-light/50">{stat.label}</p>
</div>
</a>
{:else}
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<div
class="w-10 h-10 rounded-xl {stat.bg} flex items-center justify-center"
>
<span
class="material-symbols-rounded {stat.color}"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{stat.icon}
</span>
</div>
<div>
<p class="text-2xl font-bold text-white">
{stat.value}
</p>
<p class="text-body-sm text-light/50">{stat.label}</p>
</div>
</div>
{/if}
{/each}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0">
<!-- Recent Activity -->
<div
class="lg:col-span-2 bg-night rounded-2xl p-5 flex flex-col gap-4 min-h-0"
>
<h2 class="text-h3 font-heading text-white">
{m.activity_title()}
</h2>
{#if recentActivity.length === 0}
<div
class="flex-1 flex flex-col items-center justify-center text-light/40 py-12"
>
<span
class="material-symbols-rounded mb-3"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
>
history
</span>
<p class="text-body">{m.activity_empty()}</p>
</div>
{:else}
<div class="flex flex-col gap-1 overflow-auto flex-1">
{#each recentActivity as entry}
<div
class="flex items-start gap-3 px-3 py-2.5 rounded-xl hover:bg-dark/50 transition-colors"
>
<span
class="material-symbols-rounded {getActivityColor(
entry.action,
)} mt-0.5 shrink-0"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
{getActivityIcon(entry.action)}
</span>
<div class="flex-1 min-w-0">
<p
class="text-body-sm text-light leading-relaxed"
>
{getActivityDescription(entry)}
</p>
<p class="text-[11px] text-light/40 mt-0.5">
{formatTimeAgo(entry.created_at)}
</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Sidebar: Quick Links + Members -->
<div class="flex flex-col gap-6">
<!-- Quick Links -->
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<h2 class="text-h3 font-heading text-white">
{m.overview_quick_links()}
</h2>
<div class="flex flex-col gap-1">
{#each quickLinks as link}
<a
href={link.href}
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-light hover:bg-dark/50 hover:text-white transition-colors"
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
{link.icon}
</span>
<span class="text-body">{link.label}</span>
</a>
{/each}
</div>
</div>
<!-- Team Members Preview -->
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<div class="flex items-center justify-between">
<h2 class="text-h3 font-heading text-white">
{m.overview_stat_members()}
</h2>
<span class="text-body-sm text-light/40"
>{stats.memberCount}</span
>
</div>
<div class="flex flex-col gap-2">
{#each members.slice(0, 5) as member}
<div class="flex items-center gap-3 px-1 py-1">
<Avatar
name={member.profiles?.full_name ||
member.profiles?.email ||
"?"}
src={member.profiles?.avatar_url}
size="sm"
/>
<div class="flex-1 min-w-0">
<p class="text-body-sm text-white truncate">
{member.profiles?.full_name ||
member.profiles?.email ||
"Unknown"}
</p>
<p class="text-[11px] text-light/40 capitalize">
{member.role}
</p>
</div>
</div>
{/each}
{#if stats.memberCount > 5}
<a
href="/{data.org.slug}/settings"
class="text-body-sm text-primary hover:underline text-center pt-1"
>
+{stats.memberCount - 5} more
</a>
{/if}
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,29 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
export const load: PageServerLoad = async ({ parent, locals }) => {
const { user } = await parent() as OrgLayoutData;
const [profileResult, prefsResult] = await Promise.all([
locals.supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single(),
locals.supabase
.from('user_preferences')
.select('*')
.eq('user_id', user.id)
.single()
]);
if (profileResult.error || !profileResult.data) {
error(500, 'Failed to load profile');
}
return {
profile: profileResult.data,
preferences: prefsResult.data
};
};

View File

@@ -0,0 +1,485 @@
<script lang="ts">
import { getContext } from "svelte";
import { invalidateAll } from "$app/navigation";
import { Button, Input, Avatar, Select } from "$lib/components/ui";
import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
import { getLocale, setLocale, locales } from "$lib/paraglide/runtime.js";
interface Props {
data: {
org: { id: string; slug: string };
profile: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
};
preferences: {
id: string;
theme: string | null;
accent_color: string | null;
sidebar_collapsed: boolean | null;
use_org_theme: boolean | null;
} | null;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
// Profile state
let fullName = $state(data.profile.full_name ?? "");
let avatarUrl = $state(data.profile.avatar_url ?? null);
let isSaving = $state(false);
let isUploading = $state(false);
let avatarInput = $state<HTMLInputElement | null>(null);
// Preferences state
let theme = $state(data.preferences?.theme ?? "dark");
let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0");
let useOrgTheme = $state(data.preferences?.use_org_theme ?? true);
let currentLocale = $state<(typeof locales)[number]>(getLocale());
const localeLabels: Record<string, string> = {
en: "English",
et: "Eesti",
};
function handleLanguageChange(newLocale: (typeof locales)[number]) {
currentLocale = newLocale;
setLocale(newLocale);
}
$effect(() => {
fullName = data.profile.full_name ?? "";
avatarUrl = data.profile.avatar_url ?? null;
theme = data.preferences?.theme ?? "dark";
accentColor = data.preferences?.accent_color ?? "#00A3E0";
useOrgTheme = data.preferences?.use_org_theme ?? true;
});
// Try to extract Google avatar from auth metadata
async function syncGoogleAvatar() {
const {
data: { user },
} = await supabase.auth.getUser();
const googleAvatar =
user?.user_metadata?.avatar_url || user?.user_metadata?.picture;
if (!googleAvatar) {
toasts.error("No Google avatar found.");
return;
}
const { error } = await supabase
.from("profiles")
.update({ avatar_url: googleAvatar })
.eq("id", data.profile.id);
if (error) {
toasts.error("Failed to sync avatar.");
return;
}
avatarUrl = googleAvatar;
await invalidateAll();
toasts.success("Google avatar synced.");
}
async function handleAvatarUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toasts.error("Please select an image file.");
return;
}
if (file.size > 2 * 1024 * 1024) {
toasts.error("Image must be under 2MB.");
return;
}
isUploading = true;
try {
const ext = file.name.split(".").pop() || "png";
const path = `user-avatars/${data.profile.id}.${ext}`;
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(path, file, { upsert: true });
if (uploadError) {
toasts.error("Failed to upload avatar.");
return;
}
const { data: urlData } = supabase.storage
.from("avatars")
.getPublicUrl(path);
const publicUrl = `${urlData.publicUrl}?t=${Date.now()}`;
const { error: dbError } = await supabase
.from("profiles")
.update({ avatar_url: publicUrl })
.eq("id", data.profile.id);
if (dbError) {
toasts.error("Failed to save avatar.");
return;
}
avatarUrl = publicUrl;
await invalidateAll();
toasts.success("Avatar updated.");
} catch {
toasts.error("Avatar upload failed.");
} finally {
isUploading = false;
input.value = "";
}
}
async function removeAvatar() {
const { error } = await supabase
.from("profiles")
.update({ avatar_url: null })
.eq("id", data.profile.id);
if (error) {
toasts.error("Failed to remove avatar.");
return;
}
avatarUrl = null;
await invalidateAll();
toasts.success("Avatar removed.");
}
async function saveProfile() {
isSaving = true;
const { error } = await supabase
.from("profiles")
.update({ full_name: fullName || null })
.eq("id", data.profile.id);
if (error) {
toasts.error("Failed to save profile.");
} else {
await invalidateAll();
toasts.success("Profile saved.");
}
isSaving = false;
}
async function savePreferences() {
isSaving = true;
const prefs = {
theme,
accent_color: accentColor,
use_org_theme: useOrgTheme,
user_id: data.profile.id,
};
if (data.preferences) {
const { error } = await supabase
.from("user_preferences")
.update(prefs)
.eq("id", data.preferences.id);
if (error) {
toasts.error("Failed to save preferences.");
isSaving = false;
return;
}
} else {
const { error } = await supabase
.from("user_preferences")
.insert(prefs);
if (error) {
toasts.error("Failed to save preferences.");
isSaving = false;
return;
}
}
await invalidateAll();
toasts.success("Preferences saved.");
isSaving = false;
}
const accentColors = [
{ value: "#00A3E0", label: "Blue (Default)" },
{ value: "#33E000", label: "Green" },
{ value: "#E03D00", label: "Red" },
{ value: "#FFAB00", label: "Amber" },
{ value: "#A855F7", label: "Purple" },
{ value: "#EC4899", label: "Pink" },
{ value: "#6366F1", label: "Indigo" },
{ value: "#14B8A6", label: "Teal" },
];
</script>
<svelte:head>
<title>Account Settings | Root</title>
</svelte:head>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<div>
<h1 class="font-heading text-h1 text-white">{m.account_title()}</h1>
<p class="font-body text-body text-light/60 mt-1">
{m.account_subtitle()}
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 flex-1 min-h-0">
<!-- Profile Section -->
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
<h2 class="font-heading text-h3 text-white">
{m.account_profile()}
</h2>
<!-- Avatar -->
<div class="flex flex-col gap-3">
<span class="font-body text-body-sm text-light"
>{m.account_photo()}</span
>
<div class="flex items-center gap-4">
<Avatar
name={fullName || data.profile.email}
src={avatarUrl}
size="xl"
/>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<input
type="file"
accept="image/*"
class="hidden"
bind:this={avatarInput}
onchange={handleAvatarUpload}
/>
<Button
variant="secondary"
size="sm"
onclick={() => avatarInput?.click()}
loading={isUploading}
>
{m.btn_upload()}
</Button>
<Button
variant="tertiary"
size="sm"
onclick={syncGoogleAvatar}
>
{m.account_sync_google()}
</Button>
</div>
{#if avatarUrl}
<Button
variant="tertiary"
size="sm"
onclick={removeAvatar}
>
{m.account_remove_photo()}
</Button>
{/if}
</div>
</div>
</div>
<!-- Name -->
<Input
label={m.account_display_name()}
bind:value={fullName}
placeholder={m.account_display_name_placeholder()}
/>
<!-- Email (read-only) -->
<Input
label={m.account_email()}
value={data.profile.email}
disabled
/>
<div>
<Button onclick={saveProfile} loading={isSaving}>
{m.account_save_profile()}
</Button>
</div>
</div>
<!-- Appearance Section -->
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
<h2 class="font-heading text-h3 text-white">
{m.account_appearance()}
</h2>
<!-- Theme -->
<Select
label={m.account_theme()}
bind:value={theme}
placeholder=""
options={[
{ value: "dark", label: m.account_theme_dark() },
{ value: "light", label: m.account_theme_light() },
{ value: "system", label: m.account_theme_system() },
]}
/>
<!-- Accent Color -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light"
>{m.account_accent_color()}</span
>
<div class="flex flex-wrap gap-2 items-center">
{#each accentColors as color}
<button
type="button"
class="w-8 h-8 rounded-full border-2 transition-all {accentColor ===
color.value
? 'border-white scale-110'
: 'border-transparent hover:scale-105'}"
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"
title="Custom color"
>
<input
type="color"
class="opacity-0 absolute w-0 h-0"
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;"
>
colorize
</span>
</label>
</div>
</div>
<!-- Use Org Theme -->
<div class="flex items-center justify-between">
<div>
<p class="font-body text-body text-white">
{m.account_use_org_theme()}
</p>
<p class="font-body text-[12px] text-light/50">
{m.account_use_org_theme_desc()}
</p>
</div>
<button
type="button"
class="w-11 h-6 rounded-full transition-colors {useOrgTheme
? 'bg-primary'
: 'bg-light/20'}"
onclick={() => (useOrgTheme = !useOrgTheme)}
aria-label="Toggle organization theme"
>
<div
class="w-5 h-5 bg-white rounded-full shadow transition-transform {useOrgTheme
? 'translate-x-[22px]'
: 'translate-x-[2px]'}"
></div>
</button>
</div>
<!-- Language -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light"
>{m.account_language()}</span
>
<p class="font-body text-[12px] text-light/50">
{m.account_language_desc()}
</p>
<div class="flex gap-2 mt-1">
{#each locales as locale}
<button
type="button"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {currentLocale ===
locale
? 'bg-primary text-night'
: 'bg-light/10 text-light/70 hover:bg-light/20'}"
onclick={() => handleLanguageChange(locale)}
>
{localeLabels[locale] ?? locale}
</button>
{/each}
</div>
</div>
<div>
<Button onclick={savePreferences} loading={isSaving}>
{m.account_save_preferences()}
</Button>
</div>
</div>
<!-- Security & Sessions Section -->
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
<h2 class="font-heading text-h3 text-white">
{m.account_security()}
</h2>
<div class="flex flex-col gap-2">
<p class="font-body text-body text-white">
{m.account_password()}
</p>
<p class="font-body text-body-sm text-light/50">
{m.account_password_desc()}
</p>
<div class="mt-2">
<Button
variant="secondary"
size="sm"
onclick={async () => {
const { error } =
await supabase.auth.resetPasswordForEmail(
data.profile.email,
{
redirectTo: `${window.location.origin}/${data.org.slug}/account`,
},
);
if (error)
toasts.error("Failed to send reset email.");
else toasts.success("Password reset email sent.");
}}
>
{m.account_send_reset()}
</Button>
</div>
</div>
<div class="border-t border-light/10 pt-4 flex flex-col gap-2">
<p class="font-body text-body text-white">
{m.account_active_sessions()}
</p>
<p class="font-body text-body-sm text-light/50">
{m.account_sessions_desc()}
</p>
<div class="mt-2">
<Button
variant="danger"
size="sm"
onclick={async () => {
await supabase.auth.signOut({ scope: "others" });
toasts.success("Other sessions signed out.");
}}
>
{m.account_signout_others()}
</Button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,10 +1,11 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.calendar');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org, userRole } = await parent();
const { org, userRole } = await parent() as OrgLayoutData;
const { supabase } = locals;
// Fetch events for current month ± 1 month

View File

@@ -1,6 +1,14 @@
<script lang="ts">
import { getContext, onMount } from "svelte";
import { Button, Modal, Avatar } from "$lib/components/ui";
import { getContext, onMount, onDestroy } from "svelte";
import { createLogger } from "$lib/utils/logger";
import {
Button,
Modal,
Avatar,
ContextMenu,
Input,
Textarea,
} from "$lib/components/ui";
import { Calendar } from "$lib/components/calendar";
import {
getCalendarSubscribeUrl,
@@ -9,6 +17,7 @@
import type { CalendarEvent } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
@@ -22,6 +31,7 @@
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const log = createLogger("page.calendar");
let events = $state(data.events);
$effect(() => {
@@ -32,17 +42,86 @@
let isLoadingGoogle = $state(false);
let orgCalendarId = $state<string | null>(null);
let orgCalendarName = $state<string | null>(null);
// Track Google event IDs that are pending deletion to prevent ghost re-appearance
let deletedGoogleEventIds = $state<Set<string>>(new Set());
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
const allEvents = $derived([...events, ...googleEvents]);
// Deduplicate: exclude Google Calendar events that already exist locally (synced events)
// Also exclude events that are pending deletion
const allEvents = $derived.by(() => {
const localGoogleIds = new Set(
events
.filter((e) => e.google_event_id)
.map((e) => e.google_event_id),
);
const filteredGoogle = googleEvents.filter((ge) => {
const rawId = ge.id.replace("google-", "");
if (localGoogleIds.has(rawId)) return false;
if (deletedGoogleEventIds.has(rawId)) return false;
return true;
});
return [...events, ...filteredGoogle];
});
let showEventModal = $state(false);
let showEventFormModal = $state(false);
let eventFormMode = $state<"create" | "edit">("create");
let isDeleting = $state(false);
let isSavingEvent = $state(false);
let selectedEvent = $state<CalendarEvent | null>(null);
function handleDateClick(_date: Date) {
// Event creation disabled
// Event form state
let eventTitle = $state("");
let eventDescription = $state("");
let eventDate = $state("");
let eventStartTime = $state("09:00");
let eventEndTime = $state("10:00");
let eventAllDay = $state(false);
let eventColor = $state("#7986cb");
let syncToGoogleCal = $state(true);
// Google Calendar official event colors (colorId → hex)
const GCAL_COLORS: Record<string, string> = {
"1": "#7986cb", // Lavender
"2": "#33b679", // Sage
"3": "#8e24aa", // Grape
"4": "#e67c73", // Flamingo
"5": "#f6bf26", // Banana
"6": "#f4511e", // Tangerine
"7": "#039be5", // Peacock
"8": "#616161", // Graphite
"9": "#3f51b5", // Blueberry
"10": "#0b8043", // Basil
"11": "#d50000", // Tomato
};
// Reverse map: hex → colorId (for pushing to Google)
const HEX_TO_COLOR_ID: Record<string, string> = Object.fromEntries(
Object.entries(GCAL_COLORS).map(([id, hex]) => [hex, id]),
);
const EVENT_COLORS = Object.values(GCAL_COLORS);
function toLocalDateString(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function handleDateClick(date: Date) {
eventFormMode = "create";
eventTitle = "";
eventDescription = "";
eventDate = toLocalDateString(date);
eventStartTime = "09:00";
eventEndTime = "10:00";
eventAllDay = false;
eventColor = "#7986cb";
syncToGoogleCal = isOrgCalendarConnected;
showEventFormModal = true;
}
function handleEventClick(event: CalendarEvent) {
@@ -50,11 +129,207 @@
showEventModal = true;
}
function openEditEvent() {
if (!selectedEvent || selectedEvent.id.startsWith("google-")) return;
eventFormMode = "edit";
eventTitle = selectedEvent.title;
eventDescription = selectedEvent.description ?? "";
const start = new Date(selectedEvent.start_time);
const end = new Date(selectedEvent.end_time);
eventDate = toLocalDateString(start);
eventStartTime = start.toTimeString().slice(0, 5);
eventEndTime = end.toTimeString().slice(0, 5);
eventAllDay = selectedEvent.all_day ?? false;
eventColor = selectedEvent.color ?? "#7986cb";
showEventModal = false;
showEventFormModal = true;
}
/**
* Push event to Google Calendar in the background.
* Does not block the UI — updates google_event_id on success.
*/
async function syncToGoogle(
action: "create" | "update" | "delete",
eventData: {
id?: string;
google_event_id?: string | null;
title?: string;
description?: string | null;
start_time?: string;
end_time?: string;
all_day?: boolean;
color?: string;
},
) {
const colorId = eventData.color
? (HEX_TO_COLOR_ID[eventData.color] ?? undefined)
: undefined;
try {
if (action === "create") {
const res = await fetch("/api/google-calendar/push", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
title: eventData.title,
description: eventData.description,
start_time: eventData.start_time,
end_time: eventData.end_time,
all_day: eventData.all_day,
color_id: colorId,
}),
});
if (res.ok) {
const { google_event_id } = await res.json();
if (google_event_id && eventData.id) {
// Store the Google event ID back to Supabase
await supabase
.from("calendar_events")
.update({
google_event_id,
synced_at: new Date().toISOString(),
})
.eq("id", eventData.id);
// Update local state
events = events.map((e) =>
e.id === eventData.id
? {
...e,
google_event_id,
synced_at: new Date().toISOString(),
}
: e,
);
}
}
} else if (action === "update" && eventData.google_event_id) {
await fetch("/api/google-calendar/push", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
google_event_id: eventData.google_event_id,
title: eventData.title,
description: eventData.description,
start_time: eventData.start_time,
end_time: eventData.end_time,
all_day: eventData.all_day,
color_id: colorId,
}),
});
} else if (action === "delete" && eventData.google_event_id) {
await fetch("/api/google-calendar/push", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
google_event_id: eventData.google_event_id,
}),
});
}
} catch (e) {
log.error("Google Calendar sync failed", {
error: e,
data: { action },
});
}
}
async function handleSaveEvent() {
if (!eventTitle.trim() || !eventDate) return;
isSavingEvent = true;
const startTime = eventAllDay
? `${eventDate}T00:00:00`
: `${eventDate}T${eventStartTime}:00`;
const endTime = eventAllDay
? `${eventDate}T23:59:59`
: `${eventDate}T${eventEndTime}:00`;
if (eventFormMode === "edit" && selectedEvent) {
const { error } = await supabase
.from("calendar_events")
.update({
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
})
.eq("id", selectedEvent.id);
if (!error) {
events = events.map((e) =>
e.id === selectedEvent!.id
? {
...e,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
}
: e,
);
// Push update to Google Calendar in background
syncToGoogle("update", {
google_event_id: selectedEvent.google_event_id,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
});
}
} else {
const { data: newEvent, error } = await supabase
.from("calendar_events")
.insert({
org_id: data.org.id,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
created_by: data.user?.id,
})
.select()
.single();
if (!error && newEvent) {
events = [...events, newEvent as CalendarEvent];
// Push new event to Google Calendar if sync is enabled
if (syncToGoogleCal && isOrgCalendarConnected) {
syncToGoogle("create", {
id: newEvent.id,
title: eventTitle.trim(),
description: eventDescription.trim() || null,
start_time: startTime,
end_time: endTime,
all_day: eventAllDay,
color: eventColor,
});
}
}
}
showEventFormModal = false;
selectedEvent = null;
isSavingEvent = false;
}
async function handleDeleteEvent() {
if (!selectedEvent || selectedEvent.id.startsWith("google-")) return;
isDeleting = true;
try {
const googleEventId = selectedEvent.google_event_id;
const { error } = await supabase
.from("calendar_events")
.delete()
@@ -62,11 +337,25 @@
if (!error) {
events = events.filter((e) => e.id !== selectedEvent?.id);
if (googleEventId) {
// Immediately exclude from Google events display
deletedGoogleEventIds = new Set([
...deletedGoogleEventIds,
googleEventId,
]);
googleEvents = googleEvents.filter(
(ge) => ge.id !== `google-${googleEventId}`,
);
// Await Google delete so it completes before any refresh
await syncToGoogle("delete", {
google_event_id: googleEventId,
});
}
showEventModal = false;
selectedEvent = null;
}
} catch (e) {
console.error("Failed to delete event:", e);
log.error("Failed to delete event", { error: e });
}
isDeleting = false;
}
@@ -78,8 +367,33 @@
return `${start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
let pollInterval: ReturnType<typeof setInterval> | null = null;
function handleWindowFocus() {
if (isOrgCalendarConnected) {
loadGoogleCalendarEvents();
}
}
onMount(async () => {
await loadGoogleCalendarEvents();
// Re-fetch when user returns to the tab (e.g. after editing in Google Calendar)
window.addEventListener("focus", handleWindowFocus);
// Poll every 60s for changes made in Google Calendar
pollInterval = setInterval(() => {
if (isOrgCalendarConnected && !document.hidden) {
loadGoogleCalendarEvents();
}
}, 60_000);
});
onDestroy(() => {
if (typeof window !== "undefined") {
window.removeEventListener("focus", handleWindowFocus);
}
if (pollInterval) clearInterval(pollInterval);
});
async function loadGoogleCalendarEvents() {
@@ -96,31 +410,37 @@
orgCalendarId = result.calendar_id;
orgCalendarName = result.calendar_name;
if (result.events && result.events.length > 0) {
googleEvents = result.events.map(
(ge: GoogleCalendarEvent) => ({
id: `google-${ge.id}`,
org_id: data.org.id,
title: ge.summary || "(No title)",
description: ge.description ?? null,
start_time:
ge.start.dateTime ||
`${ge.start.date}T00:00:00`,
end_time:
ge.end.dateTime || `${ge.end.date}T23:59:59`,
all_day: !ge.start.dateTime,
color: "#4285f4",
recurrence: null,
created_by: data.user?.id ?? "",
created_at: new Date().toISOString(),
}),
) as CalendarEvent[];
}
const fetchedEvents = result.events ?? [];
googleEvents = fetchedEvents.map((ge: GoogleCalendarEvent) => ({
id: `google-${ge.id}`,
org_id: data.org.id,
title: ge.summary || "(No title)",
description: ge.description ?? null,
start_time:
ge.start.dateTime || `${ge.start.date}T00:00:00`,
end_time: ge.end.dateTime || `${ge.end.date}T23:59:59`,
all_day: !ge.start.dateTime,
color: ge.colorId
? (GCAL_COLORS[ge.colorId] ?? "#7986cb")
: "#7986cb",
recurrence: null,
created_by: data.user?.id ?? "",
created_at: new Date().toISOString(),
})) as CalendarEvent[];
// Clear deleted IDs that Google has confirmed are gone
const fetchedIds = new Set(
fetchedEvents.map((ge: GoogleCalendarEvent) => ge.id),
);
deletedGoogleEventIds = new Set(
[...deletedGoogleEventIds].filter((id) =>
fetchedIds.has(id),
),
);
} else if (result.error) {
console.error("Calendar API error:", result.error);
log.error("Calendar API error", { error: result.error });
}
} catch (e) {
console.error("Failed to load Google events:", e);
log.error("Failed to load Google events", { error: e });
}
isLoadingGoogle = false;
}
@@ -139,36 +459,49 @@
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<Avatar name="Calendar" size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">Calendar</h1>
{#if isOrgCalendarConnected}
<button
type="button"
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary/10 text-primary rounded-[32px] hover:bg-primary/20 transition-colors"
onclick={subscribeToCalendar}
title="Add to your Google Calendar"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>
add
</span>
Subscribe
</button>
{/if}
<button
type="button"
class="p-1 hover:bg-dark rounded-lg transition-colors"
aria-label="More options"
<h1 class="flex-1 font-heading text-h1 text-white">
{m.calendar_title()}
</h1>
<Button size="md" onclick={() => handleDateClick(new Date())}
>{m.btn_new()}</Button
>
<span
class="material-symbols-rounded text-light"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
more_horiz
</span>
</button>
<ContextMenu
items={[
...(isOrgCalendarConnected
? [
{
label: m.calendar_subscribe(),
icon: "add",
onclick: subscribeToCalendar,
},
]
: []),
{
label: m.calendar_refresh(),
icon: "refresh",
onclick: () => {
loadGoogleCalendarEvents();
},
},
...(isAdmin
? [
{
label: "",
icon: "",
onclick: () => {},
divider: true,
},
{
label: m.calendar_settings(),
icon: "settings",
onclick: () => {
window.location.href = `/${data.org.slug}/settings?tab=integrations`;
},
},
]
: []),
]}
/>
</header>
<!-- Calendar Grid -->
@@ -270,18 +603,22 @@
<span class="text-light/60 text-sm">
{selectedEvent.id.startsWith("google-")
? "Google Calendar Event"
: "Local Event"}
: selectedEvent.google_event_id
? "Synced to Google Calendar"
: "Local Event"}
</span>
</div>
<!-- Google Calendar link -->
{#if selectedEvent.id.startsWith("google-") && orgCalendarId}
{#if (selectedEvent.id.startsWith("google-") ? selectedEvent.id.replace("google-", "") : selectedEvent.google_event_id) && orgCalendarId}
{@const googleId = selectedEvent.id.startsWith("google-")
? selectedEvent.id.replace("google-", "")
: selectedEvent.google_event_id}
<div class="pt-3 border-t border-light/10">
<a
href="https://calendar.google.com/calendar/u/0/r/eventedit/{selectedEvent.id.replace(
'google-',
'',
)}?cid={encodeURIComponent(orgCalendarId)}"
href="https://calendar.google.com/calendar/u/0/r/eventedit/{googleId}?cid={encodeURIComponent(
orgCalendarId,
)}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-500/20 text-blue-400 rounded-lg hover:bg-blue-500/30 transition-colors"
@@ -306,15 +643,14 @@
</svg>
Open in Google Calendar
</a>
<p class="text-xs text-light/40 mt-2">
Edit this event directly in Google Calendar
</p>
</div>
{/if}
<!-- Delete local event -->
<!-- Edit/Delete local event -->
{#if !selectedEvent.id.startsWith("google-")}
<div class="pt-3 border-t border-light/10">
<div
class="pt-3 border-t border-light/10 flex items-center justify-between"
>
<Button
variant="danger"
onclick={handleDeleteEvent}
@@ -322,8 +658,137 @@
>
Delete Event
</Button>
<Button onclick={openEditEvent}>Edit Event</Button>
</div>
{/if}
</div>
{/if}
</Modal>
<!-- Event Create/Edit Form Modal -->
<Modal
isOpen={showEventFormModal}
onClose={() => (showEventFormModal = false)}
title={eventFormMode === "edit"
? m.calendar_edit_event()
: m.calendar_create_event()}
>
<div class="space-y-4">
<Input
label={m.calendar_event_title()}
bind:value={eventTitle}
placeholder={m.calendar_event_title_placeholder()}
/>
<Input
type="date"
label={m.calendar_event_date()}
bind:value={eventDate}
/>
<label
class="flex items-center gap-2 text-sm text-light cursor-pointer"
>
<input type="checkbox" bind:checked={eventAllDay} class="rounded" />
All day event
</label>
{#if !eventAllDay}
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<span class="px-3 font-bold font-body text-body text-white"
>Start</span
>
<input
type="time"
class="w-full p-3 bg-background text-white rounded-[32px] font-medium font-input text-body focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={eventStartTime}
/>
</div>
<div class="flex flex-col gap-1">
<span class="px-3 font-bold font-body text-body text-white"
>End</span
>
<input
type="time"
class="w-full p-3 bg-background text-white rounded-[32px] font-medium font-input text-body focus:outline-none focus:ring-2 focus:ring-primary"
bind:value={eventEndTime}
/>
</div>
</div>
{/if}
<Textarea
label={m.calendar_event_desc()}
bind:value={eventDescription}
placeholder="Add a description..."
rows={3}
/>
<div>
<span class="block text-sm font-medium text-light mb-2">Color</span>
<div class="flex gap-2">
{#each EVENT_COLORS as color}
<button
type="button"
class="w-7 h-7 rounded-full transition-transform {eventColor ===
color
? 'ring-2 ring-white scale-110'
: ''}"
style="background-color: {color}"
onclick={() => (eventColor = color)}
></button>
{/each}
</div>
</div>
{#if isOrgCalendarConnected && eventFormMode === "create"}
<label
class="flex items-center gap-3 text-sm text-light cursor-pointer p-3 rounded-lg bg-light/5 border border-light/10"
>
<input
type="checkbox"
bind:checked={syncToGoogleCal}
class="rounded"
/>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-blue-400" viewBox="0 0 24 24">
<path
fill="currentColor"
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"
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"
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"
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>
<span>Sync to Google Calendar</span>
</div>
</label>
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
onclick={() => (showEventFormModal = false)}
>{m.btn_cancel()}</Button
>
<Button
onclick={handleSaveEvent}
loading={isSavingEvent}
disabled={!eventTitle.trim() || !eventDate}
>{eventFormMode === "edit"
? m.btn_save()
: m.btn_create()}</Button
>
</div>
</div>
</Modal>

View File

@@ -1,15 +1,16 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.documents');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org } = await parent();
const { org } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { data: documents, error } = await supabase
.from('documents')
.select('*')
.select('id, name, type, parent_id, path, position, created_at, updated_at, created_by, org_id')
.eq('org_id', org.id)
.order('name');

View File

@@ -1,11 +1,12 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { error, redirect } from '@sveltejs/kit';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.document');
export const load: PageServerLoad = async ({ parent, locals, params }) => {
const { org } = await parent() as { org: { id: string; slug: string } };
const { org } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { id } = params;

View File

@@ -1,11 +1,12 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { error, redirect } from '@sveltejs/kit';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.file');
export const load: PageServerLoad = async ({ parent, locals, params }) => {
const { org, user } = await parent() as { org: { id: string; slug: string }; user: { id: string } | null };
const { org, user } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { id } = params;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { getContext, onDestroy, onMount } from "svelte";
import { Button, Modal, Input } from "$lib/components/ui";
import { Button, Modal, Input, ContextMenu } from "$lib/components/ui";
import { DocumentViewer } from "$lib/components/documents";
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
import {
@@ -197,9 +197,11 @@
$effect(() => {
if (!kanbanBoard) return;
const colIds = kanbanBoard.columns.map((c) => c.id);
const channel = subscribeToBoard(
supabase,
kanbanBoard.id,
colIds,
() => loadKanbanBoard(),
() => loadKanbanBoard(),
);
@@ -212,15 +214,34 @@
};
});
// Reliable lock release via sendBeacon (survives page unload)
function beaconReleaseLock() {
if (hasLock && data.user) {
navigator.sendBeacon(
"/api/release-lock",
JSON.stringify({ documentId: data.document.id }),
);
}
}
onMount(() => {
window.addEventListener("beforeunload", beaconReleaseLock);
return () =>
window.removeEventListener("beforeunload", beaconReleaseLock);
});
onDestroy(() => {
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
}
// Release document lock
// Release document lock (SPA navigation — sendBeacon as fallback)
if (hasLock && data.user) {
stopHeartbeat?.();
releaseLock(supabase, data.document.id, data.user.id);
}
if (typeof window !== "undefined") {
window.removeEventListener("beforeunload", beaconReleaseLock);
}
});
async function handleCardMove(
@@ -441,23 +462,27 @@
<h1 class="flex-1 font-heading text-h1 text-white truncate">
{data.document.name}
</h1>
<Button
variant="tertiary"
size="sm"
icon="upload"
onclick={triggerImport}
loading={isImporting}
>
Import JSON
</Button>
<Button
variant="tertiary"
size="sm"
icon="download"
onclick={handleExportJson}
>
Export JSON
</Button>
<ContextMenu
items={[
{
label: "Import JSON",
icon: "upload",
onclick: triggerImport,
},
{
label: "Export JSON",
icon: "download",
onclick: handleExportJson,
},
{ divider: true, label: "", icon: "", onclick: () => {} },
{
label: "Rename Board",
icon: "edit",
onclick: () =>
toasts.info("Rename from the documents list."),
},
]}
/>
</header>
<div class="flex-1 overflow-auto min-h-0">

View File

@@ -1,11 +1,12 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { error } from '@sveltejs/kit';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.folder');
export const load: PageServerLoad = async ({ parent, locals, params }) => {
const { org, user } = await parent() as { org: { id: string; slug: string }; user: { id: string } | null };
const { org, user } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { id } = params;
@@ -13,7 +14,7 @@ export const load: PageServerLoad = async ({ parent, locals, params }) => {
const { data: document, error: docError } = await supabase
.from('documents')
.select('*')
.select('id, name, type, parent_id, path, position, created_at, updated_at, created_by, org_id')
.eq('org_id', org.id)
.eq('id', id)
.single();
@@ -31,7 +32,7 @@ export const load: PageServerLoad = async ({ parent, locals, params }) => {
// Load all documents in this org (for breadcrumb building and file listing)
const { data: allDocuments } = await supabase
.from('documents')
.select('*')
.select('id, name, type, parent_id, path, position, created_at, updated_at, created_by, org_id')
.eq('org_id', org.id)
.order('name');

View File

@@ -1,10 +1,11 @@
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.kanban');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org } = await parent();
const { org } = await parent() as OrgLayoutData;
const { supabase } = locals;
const { data: boards, error } = await supabase

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, onDestroy } from "svelte";
import { getContext, onDestroy, untrack } from "svelte";
import {
Button,
Card,
@@ -8,6 +8,7 @@
Avatar,
IconButton,
Icon,
ContextMenu,
} from "$lib/components/ui";
import { KanbanBoard, CardDetailModal } from "$lib/components/kanban";
import {
@@ -16,26 +17,44 @@
moveCard,
subscribeToBoard,
} from "$lib/api/kanban";
import type { RealtimeChangePayload } from "$lib/api/kanban";
import type { RealtimeChannel } from "@supabase/supabase-js";
import type {
KanbanBoard as KanbanBoardType,
KanbanCard,
KanbanColumn,
} from "$lib/supabase/types";
import type { BoardWithColumns } from "$lib/api/kanban";
import type { BoardWithColumns, ColumnWithCards } from "$lib/api/kanban";
import { createLogger } from "$lib/utils/logger";
import * as m from "$lib/paraglide/messages";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
interface Member {
id: string;
user_id: string;
role: string;
profiles: {
id: string;
full_name: string | null;
email: string;
avatar_url: string | null;
};
}
interface Props {
data: {
org: { id: string; name: string; slug: string };
boards: KanbanBoardType[];
user: { id: string } | null;
members: Member[];
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const log = createLogger("page.kanban");
let boards = $state(data.boards);
$effect(() => {
@@ -44,6 +63,8 @@
let selectedBoard = $state<BoardWithColumns | null>(null);
let showCreateBoardModal = $state(false);
let showEditBoardModal = $state(false);
let isRenamingBoard = $state(false);
let renameBoardValue = $state("");
let showCardDetailModal = $state(false);
let selectedCard = $state<KanbanCard | null>(null);
let newBoardName = $state("");
@@ -56,23 +77,125 @@
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
}
// Incremental realtime handlers
function handleColumnRealtime(
payload: RealtimeChangePayload<KanbanColumn>,
) {
if (!selectedBoard) return;
const { event } = payload;
if (event === "INSERT") {
const col: ColumnWithCards = { ...payload.new, cards: [] };
selectedBoard = {
...selectedBoard,
columns: [...selectedBoard.columns, col].sort(
(a, b) => a.position - b.position,
),
};
} else if (event === "UPDATE") {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns
.map((c) =>
c.id === payload.new.id ? { ...c, ...payload.new } : c,
)
.sort((a, b) => a.position - b.position),
};
} else if (event === "DELETE") {
const deletedId = payload.old.id;
if (deletedId) {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.filter(
(c) => c.id !== deletedId,
),
};
}
}
}
function handleCardRealtime(payload: RealtimeChangePayload<KanbanCard>) {
if (!selectedBoard) return;
const { event } = payload;
if (event === "INSERT") {
const card = payload.new;
if (!card.column_id) return;
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((col) =>
col.id === card.column_id
? {
...col,
cards: [...col.cards, card].sort(
(a, b) => a.position - b.position,
),
}
: col,
),
};
} else if (event === "UPDATE") {
const card = payload.new;
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((col) => {
if (col.id === card.column_id) {
// Target column: update existing or add new
const exists = col.cards.some((c) => c.id === card.id);
const updatedCards = exists
? col.cards.map((c) =>
c.id === card.id ? { ...c, ...card } : c,
)
: [...col.cards, card];
return {
...col,
cards: updatedCards.sort(
(a, b) => a.position - b.position,
),
};
}
// All other columns: remove the card if present (handles cross-column moves)
return {
...col,
cards: col.cards.filter((c) => c.id !== card.id),
};
}),
};
} else if (event === "DELETE") {
const deletedId = payload.old.id;
if (deletedId) {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((col) => ({
...col,
cards: col.cards.filter((c) => c.id !== deletedId),
})),
};
}
}
}
// Track board ID separately so the realtime subscription only re-runs
// when the board changes, not on every column/card update
let currentBoardId = $derived(selectedBoard?.id ?? null);
// Realtime subscription with proper cleanup
$effect(() => {
const board = selectedBoard;
if (!board) return;
const boardId = currentBoardId;
if (!boardId) return;
// Subscribe to realtime changes for this board
// Read column IDs without creating a reactive dependency on selectedBoard
// (the effect should only re-run when boardId changes)
const colIds = (untrack(() => selectedBoard)?.columns ?? []).map(
(c) => c.id,
);
const channel = subscribeToBoard(
supabase,
board.id,
() => {
// Column changed - reload board data
loadBoard(board.id);
},
() => {
// Card changed - reload board data
loadBoard(board.id);
},
boardId,
colIds,
handleColumnRealtime,
handleCardRealtime,
);
realtimeChannel = channel;
@@ -111,6 +234,48 @@
showEditBoardModal = true;
}
function startBoardRename() {
if (!selectedBoard) return;
renameBoardValue = selectedBoard.name;
isRenamingBoard = true;
}
async function confirmBoardRename() {
if (!selectedBoard || !renameBoardValue.trim()) {
isRenamingBoard = false;
return;
}
const newName = renameBoardValue.trim();
if (newName === selectedBoard.name) {
isRenamingBoard = false;
return;
}
const boardId = selectedBoard.id;
const { error } = await supabase
.from("kanban_boards")
.update({ name: newName })
.eq("id", boardId);
await supabase
.from("documents")
.update({ name: newName })
.eq("id", boardId);
if (!error) {
selectedBoard = { ...selectedBoard, name: newName };
boards = boards.map((b) =>
b.id === boardId ? { ...b, name: newName } : b,
);
}
isRenamingBoard = false;
}
function cancelBoardRename() {
isRenamingBoard = false;
renameBoardValue = "";
}
async function handleEditBoard() {
if (!editingBoardId || !editBoardName.trim()) return;
@@ -119,6 +284,12 @@
.update({ name: editBoardName })
.eq("id", editingBoardId);
// Also update the linked document entry
await supabase
.from("documents")
.update({ name: editBoardName })
.eq("id", editingBoardId);
if (!error) {
if (selectedBoard?.id === editingBoardId) {
selectedBoard = { ...selectedBoard, name: editBoardName };
@@ -131,8 +302,11 @@
editingBoardId = null;
}
async function handleDeleteBoard(e: MouseEvent, board: KanbanBoardType) {
e.stopPropagation();
async function handleDeleteBoard(
e: MouseEvent | null,
board: KanbanBoardType,
) {
e?.stopPropagation();
if (!confirm(`Delete "${board.name}" and all its cards?`)) return;
const { error } = await supabase
@@ -140,6 +314,9 @@
.delete()
.eq("id", board.id);
// Also delete the corresponding document entry
await supabase.from("documents").delete().eq("id", board.id);
if (!error) {
boards = boards.filter((b) => b.id !== board.id);
if (selectedBoard?.id === board.id) {
@@ -217,6 +394,24 @@
}
}
async function handleRenameColumn(columnId: string, newName: string) {
if (!selectedBoard) return;
const { error } = await supabase
.from("kanban_columns")
.update({ name: newName })
.eq("id", columnId);
if (!error) {
selectedBoard = {
...selectedBoard,
columns: selectedBoard.columns.map((c) =>
c.id === columnId ? { ...c, name: newName } : c,
),
};
}
}
async function handleCardMove(
cardId: string,
toColumnId: string,
@@ -244,7 +439,7 @@
// Persist to database in background
moveCard(supabase, cardId, toColumnId, toPosition).catch((err) => {
console.error("Failed to persist card move:", err);
log.error("Failed to persist card move", { error: err });
// Reload to sync state on error
loadBoard(selectedBoard!.id);
});
@@ -302,17 +497,48 @@
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<Avatar name="Kanban" size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">Kanban</h1>
{#if isRenamingBoard && selectedBoard}
<input
type="text"
class="flex-1 bg-dark border border-primary rounded-lg px-3 py-1 text-white font-heading text-h1 focus:outline-none"
bind:value={renameBoardValue}
onkeydown={(e) => {
if (e.key === "Enter") confirmBoardRename();
if (e.key === "Escape") cancelBoardRename();
}}
onblur={confirmBoardRename}
autofocus
/>
{:else}
<h1 class="flex-1 font-heading text-h1 text-white">
{selectedBoard ? selectedBoard.name : m.kanban_title()}
</h1>
{/if}
<Button size="md" onclick={() => (showCreateBoardModal = true)}
>+ New</Button
>{m.btn_new()}</Button
>
<IconButton
title="More options"
onclick={() => selectedBoard && openEditBoardModal(selectedBoard)}
>
<Icon name="more_horiz" size={24} />
</IconButton>
<ContextMenu
items={[
...(selectedBoard
? [
{
label: m.kanban_rename_board(),
icon: "edit",
onclick: () => startBoardRename(),
},
{
label: m.kanban_delete_board(),
icon: "delete",
onclick: () => {
if (selectedBoard)
handleDeleteBoard(null, selectedBoard);
},
danger: true,
},
]
: []),
]}
/>
</header>
<!-- Board selector (compact) -->
@@ -344,6 +570,7 @@
onAddColumn={handleAddColumn}
onDeleteCard={handleCardDelete}
onDeleteColumn={handleDeleteColumn}
onRenameColumn={handleRenameColumn}
/>
{:else if boards.length === 0}
<div class="h-full flex items-center justify-center text-light/40">
@@ -354,18 +581,18 @@
>
view_kanban
</span>
<p class="mb-4">Kanban boards are now managed in Files</p>
<p class="mb-4">{m.kanban_empty()}</p>
<Button
onclick={() =>
(window.location.href = `/${data.org.slug}/documents`)}
>
Go to Files
{m.kanban_go_to_files()}
</Button>
</div>
</div>
{:else}
<div class="h-full flex items-center justify-center text-light/40">
<p>Select a board above</p>
<p>{m.kanban_select_board()}</p>
</div>
{/if}
</div>
@@ -374,21 +601,22 @@
<Modal
isOpen={showCreateBoardModal}
onClose={() => (showCreateBoardModal = false)}
title="Create Board"
title={m.kanban_create_board()}
>
<div class="space-y-4">
<Input
label="Board Name"
label={m.kanban_board_name_label()}
bind:value={newBoardName}
placeholder="e.g. Sprint 1"
placeholder={m.kanban_board_name_placeholder()}
/>
<div class="flex justify-end gap-2">
<Button
variant="tertiary"
onclick={() => (showCreateBoardModal = false)}>Cancel</Button
onclick={() => (showCreateBoardModal = false)}
>{m.btn_cancel()}</Button
>
<Button onclick={handleCreateBoard} disabled={!newBoardName.trim()}
>Create</Button
>{m.btn_create()}</Button
>
</div>
</div>
@@ -397,22 +625,34 @@
<Modal
isOpen={showEditBoardModal}
onClose={() => (showEditBoardModal = false)}
title="Edit Board"
title={m.kanban_edit_board()}
>
<div class="space-y-4">
<Input
label="Board Name"
label={m.kanban_board_name_label()}
bind:value={editBoardName}
placeholder="Board name"
placeholder={m.kanban_board_name_placeholder()}
/>
<div class="flex justify-end gap-2">
<div class="flex items-center justify-between gap-2">
<Button
variant="tertiary"
onclick={() => (showEditBoardModal = false)}>Cancel</Button
>
<Button onclick={handleEditBoard} disabled={!editBoardName.trim()}
>Save</Button
variant="danger"
onclick={() => {
showEditBoardModal = false;
const board = boards.find((b) => b.id === editingBoardId);
if (board) handleDeleteBoard(null, board);
}}>{m.kanban_delete_board()}</Button
>
<div class="flex gap-2">
<Button
variant="tertiary"
onclick={() => (showEditBoardModal = false)}
>{m.btn_cancel()}</Button
>
<Button
onclick={handleEditBoard}
disabled={!editBoardName.trim()}>{m.btn_save()}</Button
>
</div>
</div>
</div>
</Modal>
@@ -420,22 +660,23 @@
<Modal
isOpen={showAddColumnModal}
onClose={() => (showAddColumnModal = false)}
title="Add Column"
title={m.kanban_add_column()}
>
<div class="space-y-4">
<Input
label="Column Name"
label={m.kanban_column_name_label()}
bind:value={newColumnName}
placeholder="e.g. To Do, In Progress, Done"
placeholder={m.kanban_column_name_placeholder()}
/>
<div class="flex justify-end gap-2">
<Button
variant="tertiary"
onclick={() => (showAddColumnModal = false)}>Cancel</Button
onclick={() => (showAddColumnModal = false)}
>{m.btn_cancel()}</Button
>
<Button
onclick={handleCreateColumn}
disabled={!newColumnName.trim()}>Create</Button
disabled={!newColumnName.trim()}>{m.btn_create()}</Button
>
</div>
</div>
@@ -448,6 +689,8 @@
showCardDetailModal = false;
selectedCard = null;
targetColumnId = null;
// Reload board to reflect tag/checklist/assignee changes made in modal
if (selectedBoard) loadBoard(selectedBoard.id);
}}
onUpdate={handleCardUpdate}
onDelete={handleCardDelete}
@@ -456,4 +699,5 @@
userId={data.user?.id}
orgId={data.org.id}
onCreate={handleCardCreated}
members={data.members ?? []}
/>

View File

@@ -1,11 +1,14 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { OrgLayoutData } from '$lib/types/layout';
import { createLogger } from '$lib/utils/logger';
import { getServiceAccountEmail } from '$lib/api/google-calendar-push';
import { env } from '$env/dynamic/private';
const log = createLogger('page.settings');
export const load: PageServerLoad = async ({ parent, locals }) => {
const { org, userRole } = await parent() as { org: { id: string; slug: string }; userRole: string };
const { org, userRole } = await parent() as OrgLayoutData;
// Only admins and owners can access settings
if (userRole !== 'owner' && userRole !== 'admin') {
@@ -16,22 +19,10 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
// Fetch all settings data in parallel
const [membersResult, rolesResult, invitesResult, calendarResult] = await Promise.all([
// Get org members with profiles
// Get org members (without embedded profile join — FK points to auth.users, not profiles)
locals.supabase
.from('org_members')
.select(`
id,
user_id,
role,
role_id,
created_at,
profiles:user_id (
id,
email,
full_name,
avatar_url
)
`)
.select('id, user_id, role, role_id, invited_at')
.eq('org_id', orgId),
// Get org roles
locals.supabase
@@ -54,11 +45,41 @@ export const load: PageServerLoad = async ({ parent, locals }) => {
.single()
]);
if (membersResult.error) {
log.error('Failed to fetch members', { error: membersResult.error, data: { orgId } });
}
// Fetch profiles separately since org_members.user_id FK points to auth.users, not profiles
const rawMembers = membersResult.data ?? [];
const userIds = rawMembers.map(m => m.user_id).filter((id): id is string => id !== null);
let profilesMap: Record<string, { id: string; email: string; full_name: string | null; avatar_url: string | null }> = {};
if (userIds.length > 0) {
const { data: profiles } = await locals.supabase
.from('profiles')
.select('id, email, full_name, avatar_url')
.in('id', userIds);
if (profiles) {
profilesMap = Object.fromEntries(profiles.map(p => [p.id, p]));
}
}
const members = rawMembers.map(m => ({
...m,
profiles: (m.user_id ? profilesMap[m.user_id] : null) ?? null
}));
const serviceAccountEmail = env.GOOGLE_SERVICE_ACCOUNT_KEY
? getServiceAccountEmail(env.GOOGLE_SERVICE_ACCOUNT_KEY)
: null;
return {
members: membersResult.data ?? [],
members,
roles: rolesResult.data ?? [],
invites: invitesResult.data ?? [],
orgCalendar: calendarResult.data,
serviceAccountEmail,
userRole
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { GOOGLE_API_KEY } from '$env/static/private';
import { env } from '$env/dynamic/private';
import { fetchPublicCalendarEvents } from '$lib/api/google-calendar';
import { fetchCalendarEventsViaServiceAccount } from '$lib/api/google-calendar-push';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api:google-calendar');
@@ -30,8 +31,11 @@ export const GET: RequestHandler = async ({ url, locals }) => {
return json({ error: 'Forbidden' }, { status: 403 });
}
if (!GOOGLE_API_KEY) {
return json({ error: 'Google API key not configured' }, { status: 500 });
const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY;
const apiKey = env.GOOGLE_API_KEY;
if (!serviceKey && !apiKey) {
return json({ error: 'No Google credentials configured' }, { status: 500 });
}
try {
@@ -53,17 +57,28 @@ export const GET: RequestHandler = async ({ url, locals }) => {
log.debug('Fetching events for calendar', { data: { calendarId: orgCal.calendar_id } });
// Fetch events for the next 3 months
const now = new Date();
const timeMin = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const timeMax = new Date(now.getFullYear(), now.getMonth() + 3, 0);
const events = await fetchPublicCalendarEvents(
orgCal.calendar_id,
GOOGLE_API_KEY,
timeMin,
timeMax
);
let events: unknown[];
// Prefer service account (works with private calendars) over public API key
if (serviceKey) {
events = await fetchCalendarEventsViaServiceAccount(
serviceKey,
orgCal.calendar_id,
timeMin,
timeMax
);
} else {
events = await fetchPublicCalendarEvents(
orgCal.calendar_id,
apiKey!,
timeMin,
timeMax
);
}
log.debug('Fetched events', { data: { count: events.length } });
@@ -74,6 +89,6 @@ export const GET: RequestHandler = async ({ url, locals }) => {
});
} catch (err) {
log.error('Failed to fetch calendar events', { data: { orgId }, error: err });
return json({ error: 'Failed to fetch events. Make sure the calendar is public.', events: [] }, { status: 500 });
return json({ error: 'Failed to fetch events. Make sure the calendar is shared with the service account.', events: [] }, { status: 500 });
}
};

View File

@@ -0,0 +1,191 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import {
pushEventToGoogle,
updateGoogleEvent,
deleteGoogleEvent,
type GoogleEventPayload,
} from '$lib/api/google-calendar-push';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.google-calendar-push');
/**
* Shared auth + org membership check
*/
async function authorize(locals: App.Locals, orgId: string): Promise<{ user: { id: string }; error?: never } | { error: Response; user?: never }> {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
return { error: json({ error: 'Unauthorized' }, { status: 401 }) };
}
const { data: membership } = await locals.supabase
.from('org_members')
.select('id')
.eq('org_id', orgId)
.eq('user_id', user.id)
.single();
if (!membership) {
return { error: json({ error: 'Forbidden' }, { status: 403 }) };
}
return { user };
}
/**
* Get the org's connected Google Calendar ID
*/
async function getOrgCalendarId(locals: App.Locals, orgId: string): Promise<string | null> {
const { data } = await locals.supabase
.from('org_google_calendars')
.select('calendar_id')
.eq('org_id', orgId)
.single();
return data?.calendar_id ?? null;
}
/**
* Build a Google Calendar event payload from our app's event data
*/
function buildGooglePayload(body: {
title: string;
description?: string | null;
start_time: string;
end_time: string;
all_day?: boolean;
color_id?: string;
}): GoogleEventPayload {
const base = {
summary: body.title,
description: body.description ?? undefined,
colorId: body.color_id ?? undefined,
};
if (body.all_day) {
const startDate = body.start_time.split('T')[0];
const endDateObj = new Date(body.end_time.split('T')[0]);
endDateObj.setDate(endDateObj.getDate() + 1);
const endDate = endDateObj.toISOString().split('T')[0];
return {
...base,
start: { date: startDate },
end: { date: endDate },
};
}
return {
...base,
start: { dateTime: new Date(body.start_time).toISOString() },
end: { dateTime: new Date(body.end_time).toISOString() },
};
}
/**
* POST /api/google-calendar/push
* Create an event in Google Calendar and return the google_event_id
*/
export const POST: RequestHandler = async ({ request, locals }) => {
const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY;
if (!serviceKey) {
return json({ error: 'Google service account not configured' }, { status: 500 });
}
const body = await request.json();
const { org_id, title, description, start_time, end_time, all_day, color_id } = body;
if (!org_id || !title || !start_time || !end_time) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const auth = await authorize(locals, org_id);
if (auth.error) return auth.error;
const calendarId = await getOrgCalendarId(locals, org_id);
if (!calendarId) {
return json({ error: 'No Google Calendar connected' }, { status: 404 });
}
try {
const payload = buildGooglePayload({ title, description, start_time, end_time, all_day, color_id });
const googleEventId = await pushEventToGoogle(serviceKey, calendarId, payload);
return json({ google_event_id: googleEventId });
} catch (err) {
log.error('Failed to push event to Google Calendar', { error: err, data: { org_id } });
return json({ error: 'Failed to create event in Google Calendar' }, { status: 500 });
}
};
/**
* PUT /api/google-calendar/push
* Update an existing event in Google Calendar
*/
export const PUT: RequestHandler = async ({ request, locals }) => {
const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY;
if (!serviceKey) {
return json({ error: 'Google service account not configured' }, { status: 500 });
}
const body = await request.json();
const { org_id, google_event_id, title, description, start_time, end_time, all_day, color_id } = body;
if (!org_id || !google_event_id || !title || !start_time || !end_time) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const auth = await authorize(locals, org_id);
if (auth.error) return auth.error;
const calendarId = await getOrgCalendarId(locals, org_id);
if (!calendarId) {
return json({ error: 'No Google Calendar connected' }, { status: 404 });
}
try {
const payload = buildGooglePayload({ title, description, start_time, end_time, all_day, color_id });
await updateGoogleEvent(serviceKey, calendarId, google_event_id, payload);
return json({ ok: true });
} catch (err) {
log.error('Failed to update Google Calendar event', { error: err, data: { org_id, google_event_id } });
return json({ error: 'Failed to update event in Google Calendar' }, { status: 500 });
}
};
/**
* DELETE /api/google-calendar/push
* Delete an event from Google Calendar
*/
export const DELETE: RequestHandler = async ({ request, locals }) => {
const serviceKey = env.GOOGLE_SERVICE_ACCOUNT_KEY;
if (!serviceKey) {
return json({ error: 'Google service account not configured' }, { status: 500 });
}
const body = await request.json();
const { org_id, google_event_id } = body;
if (!org_id || !google_event_id) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const auth = await authorize(locals, org_id);
if (auth.error) return auth.error;
const calendarId = await getOrgCalendarId(locals, org_id);
if (!calendarId) {
return json({ error: 'No Google Calendar connected' }, { status: 404 });
}
try {
await deleteGoogleEvent(serviceKey, calendarId, google_event_id);
return json({ ok: true });
} catch (err) {
log.error('Failed to delete Google Calendar event', { error: err, data: { org_id, google_event_id } });
return json({ error: 'Failed to delete event from Google Calendar' }, { status: 500 });
}
};

View File

@@ -0,0 +1,41 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.release-lock');
/**
* POST /api/release-lock
* Called via navigator.sendBeacon() when the user navigates away from a document.
* Releases the document lock so other users can edit immediately.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { documentId } = await request.json();
if (!documentId) {
return json({ error: 'Missing documentId' }, { status: 400 });
}
// Only allow releasing your own lock
const { error } = await locals.supabase
.from('document_locks')
.delete()
.eq('document_id', documentId)
.eq('user_id', user.id);
if (error) {
log.error('Failed to release lock', { error, data: { documentId, userId: user.id } });
return json({ error: 'Failed to release lock' }, { status: 500 });
}
return json({ ok: true });
} catch (e) {
log.error('release-lock request failed', { error: e });
return json({ error: 'Invalid request' }, { status: 400 });
}
};

View File

@@ -13,6 +13,21 @@ export const GET: RequestHandler = async ({ url, locals }) => {
if (code) {
const { error } = await locals.supabase.auth.exchangeCodeForSession(code);
if (!error) {
// Sync avatar from OAuth provider metadata into profiles
const { data: { user } } = await locals.supabase.auth.getUser();
if (user) {
const avatarUrl = user.user_metadata?.avatar_url || user.user_metadata?.picture || null;
const fullName = user.user_metadata?.full_name || user.user_metadata?.name || null;
if (avatarUrl || fullName) {
const updates: Record<string, string> = {};
if (avatarUrl) updates.avatar_url = avatarUrl;
if (fullName) updates.full_name = fullName;
await locals.supabase
.from('profiles')
.update(updates)
.eq('id', user.id);
}
}
redirect(303, next);
}
}

View File

@@ -32,12 +32,14 @@ export const load: PageServerLoad = async ({ params, locals }) => {
// Get current user
const { data: { user } } = await locals.supabase.auth.getUser();
const org = (invite as Record<string, unknown>).organizations as { id: string; name: string; slug: string } | null;
return {
invite: {
id: invite.id,
email: invite.email,
role: invite.role,
org: (invite as any).organizations // join not typed
org,
},
user,
token

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { Button, Card } from "$lib/components/ui";
import { createLogger } from "$lib/utils/logger";
import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
@@ -20,6 +21,7 @@
let { data }: Props = $props();
const supabase = getContext<SupabaseClient>("supabase");
const log = createLogger("page.invite");
let isAccepting = $state(false);
let error = $state(data.error || "");
@@ -55,7 +57,9 @@
error = "You're already a member of this organization.";
} else {
error = "Failed to join organization. Please try again.";
console.error(memberError);
log.error("Failed to join organization", {
error: memberError,
});
}
isAccepting = false;
return;
@@ -71,7 +75,7 @@
goto(`/${data.invite.org.slug}`);
} catch (e) {
error = "Something went wrong. Please try again.";
console.error(e);
log.error("Invite acceptance failed", { error: e });
isAccepting = false;
}
}

View File

@@ -1,5 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400,0,0&display=swap');
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@@ -68,6 +68,12 @@
h4 { @apply font-heading font-normal text-h4 leading-normal; }
h5 { @apply font-heading font-normal text-h5 leading-normal; }
h6 { @apply font-heading font-normal text-h6 leading-normal; }
button, [role="button"] { @apply cursor-pointer; }
button:disabled, [role="button"][aria-disabled="true"] { @apply cursor-not-allowed; }
a { @apply cursor-pointer; }
[draggable="true"] { @apply cursor-grab; }
[draggable="true"]:active { @apply cursor-grabbing; }
}
/* Scrollbar — no Tailwind equivalent for pseudo-elements */

View File

@@ -3,6 +3,7 @@
import { createClient } from "$lib/supabase";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import * as m from "$lib/paraglide/messages";
let email = $state($page.url.searchParams.get("email") || "");
let password = $state("");
@@ -93,8 +94,8 @@
<div class="min-h-screen bg-dark flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-primary mb-2">Root</h1>
<p class="text-light/60">Team collaboration, reimagined</p>
<h1 class="text-3xl font-bold text-primary mb-2">{m.app_name()}</h1>
<p class="text-light/60">{m.login_subtitle()}</p>
</div>
<Card variant="elevated" padding="lg">
@@ -111,12 +112,10 @@
</span>
</div>
<h2 class="text-xl font-semibold text-light mb-2">
Check your email
{m.login_signup_success_title()}
</h2>
<p class="text-light/60 text-sm mb-4">
We've sent a confirmation link to <strong
class="text-light">{email}</strong
>. Click the link to activate your account.
{m.login_signup_success_text({ email })}
</p>
<Button
variant="tertiary"
@@ -125,12 +124,14 @@
mode = "login";
}}
>
Back to Login
{m.login_tab_login()}
</Button>
</div>
{:else}
<h2 class="text-xl font-semibold text-light mb-6">
{mode === "login" ? "Welcome back" : "Create your account"}
{mode === "login"
? m.login_tab_login()
: m.login_tab_signup()}
</h2>
{#if error}
@@ -150,28 +151,32 @@
>
<Input
type="email"
label="Email"
placeholder="you@example.com"
label={m.login_email_label()}
placeholder={m.login_email_placeholder()}
bind:value={email}
required
/>
<Input
type="password"
label="Password"
placeholder="••••••••"
label={m.login_password_label()}
placeholder={m.login_password_placeholder()}
bind:value={password}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{mode === "login" ? "Log In" : "Sign Up"}
{mode === "login"
? m.login_btn_login()
: m.login_btn_signup()}
</Button>
</form>
<div class="my-6 flex items-center gap-3">
<div class="flex-1 h-px bg-light/10"></div>
<span class="text-light/40 text-sm">or continue with</span>
<span class="text-light/40 text-sm"
>{m.login_or_continue()}</span
>
<div class="flex-1 h-px bg-light/10"></div>
</div>
@@ -198,25 +203,25 @@
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>
Continue with Google
{m.login_google()}
</Button>
<p class="mt-6 text-center text-light/60 text-sm">
{#if mode === "login"}
Don't have an account?
{m.login_signup_prompt()}
<button
class="text-primary hover:underline"
onclick={() => (mode = "signup")}
>
Sign up
{m.login_tab_signup()}
</button>
{:else}
Already have an account?
{m.login_login_prompt()}
<button
class="text-primary hover:underline"
onclick={() => (mode = "login")}
>
Log in
{m.login_tab_login()}
</button>
{/if}
</p>