Mega push vol 5, working on messaging now
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
src/routes/[orgSlug]/account/+page.server.ts
Normal file
29
src/routes/[orgSlug]/account/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
485
src/routes/[orgSlug]/account/+page.svelte
Normal file
485
src/routes/[orgSlug]/account/+page.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? []}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
191
src/routes/api/google-calendar/push/+server.ts
Normal file
191
src/routes/api/google-calendar/push/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
41
src/routes/api/release-lock/+server.ts
Normal file
41
src/routes/api/release-lock/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user