Mega push vol 5, working on messaging now

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

View File

@@ -128,7 +128,7 @@
<div class="flex items-center justify-between px-2">
<div class="flex items-center gap-2">
<button
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-full transition-colors"
onclick={prev}
aria-label="Previous"
>
@@ -143,7 +143,7 @@
>{headerTitle}</span
>
<button
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-lg transition-colors"
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-full transition-colors"
onclick={next}
aria-label="Next"
>
@@ -203,7 +203,9 @@
</div>
<!-- Calendar Grid -->
<div class="flex-1 flex flex-col gap-2 min-h-0">
<div
class="flex-1 flex flex-col gap-2 min-h-0 rounded-lg overflow-hidden"
>
{#each weeks as week}
<div class="grid grid-cols-7 gap-2 flex-1">
{#each week as day}
@@ -211,7 +213,7 @@
{@const isToday = isSameDay(day, today)}
{@const inMonth = isCurrentMonth(day)}
<div
class="bg-night rounded-none flex flex-col items-start px-4 py-5 overflow-hidden transition-colors hover:bg-dark/50 min-h-0 cursor-pointer
class="bg-night rounded-none flex flex-col items-start px-2 py-2.5 overflow-hidden transition-colors hover:bg-dark/50 min-h-0 cursor-pointer
{!inMonth ? 'opacity-50' : ''}"
onclick={() => onDateClick?.(day)}
>
@@ -254,12 +256,14 @@
<div
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
>
<div class="grid grid-cols-7 gap-2 flex-1">
<div
class="grid grid-cols-7 gap-2 flex-1 rounded-lg overflow-hidden"
>
{#each weekDates as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
<div class="flex flex-col overflow-hidden">
<div class="px-4 py-3 text-center">
<div class="px-2 py-2 text-center">
<div
class="font-heading text-h4 {isToday
? 'text-primary'
@@ -275,7 +279,9 @@
{day.getDate()}
</div>
</div>
<div class="flex-1 px-2 pb-2 space-y-1 overflow-y-auto">
<div
class="bg-night flex-1 px-2 pb-2 space-y-1 overflow-y-auto"
>
{#each dayEvents as event}
<button
class="w-full px-2 py-1.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"

View File

@@ -86,7 +86,7 @@
{/if}
<button
type="button"
class="p-1 hover:bg-dark rounded-lg transition-colors"
class="p-1 hover:bg-dark rounded-full transition-colors"
aria-label="More options"
>
<span

View File

@@ -15,6 +15,15 @@
import type { Document } 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";
import { logActivity } from "$lib/api/activity";
import {
moveDocument,
updateDocument,
deleteDocument,
createDocument,
copyDocument,
} from "$lib/api/documents";
const log = createLogger("component.file-browser");
@@ -32,7 +41,7 @@
documents = $bindable(),
currentFolderId,
user,
title = "Files",
title = m.files_title(),
}: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
@@ -43,7 +52,19 @@
let editingDoc = $state<Document | null>(null);
let newDocName = $state("");
let newDocType = $state<"folder" | "document" | "kanban">("document");
let viewMode = $state<"list" | "grid">("grid");
let viewMode = $state<"list" | "grid">(
typeof localStorage !== "undefined" &&
localStorage.getItem("root:viewMode") === "list"
? "list"
: "grid",
);
function toggleViewMode() {
viewMode = viewMode === "list" ? "grid" : "list";
if (typeof localStorage !== "undefined") {
localStorage.setItem("root:viewMode", viewMode);
}
}
// Context menu state
let contextMenu = $state<{ x: number; y: number; doc: Document } | null>(
@@ -171,23 +192,11 @@
if (!contextMenu || !user) return;
const doc = contextMenu.doc;
closeContextMenu();
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
org_id: org.id,
name: `${doc.name} (copy)`,
type: doc.type,
parent_id: doc.parent_id,
created_by: user.id,
content: doc.content,
})
.select()
.single();
if (!error && newDoc) {
documents = [...documents, newDoc as Document];
try {
const newDoc = await copyDocument(supabase, doc, org.id, user.id);
documents = [...documents, newDoc];
toasts.success(`Copied "${doc.name}"`);
} else if (error) {
log.error("Failed to copy document", { error });
} catch {
toasts.error("Failed to copy document");
}
}
@@ -294,22 +303,15 @@
documents = documents.map((d) =>
d.id === docId ? { ...d, parent_id: newParentId } : d,
);
const { error } = await supabase
.from("documents")
.update({
parent_id: newParentId,
updated_at: new Date().toISOString(),
})
.eq("id", docId);
if (error) {
log.error("Failed to move document", {
error,
data: { docId, newParentId },
});
try {
await moveDocument(supabase, docId, newParentId);
} catch {
toasts.error("Failed to move file");
const { data: freshDocs } = 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");
if (freshDocs) documents = freshDocs as Document[];
@@ -319,67 +321,71 @@
async function handleCreate() {
if (!newDocName.trim() || !user) return;
if (newDocType === "kanban") {
const { data: newBoard, error: boardError } = await supabase
.from("kanban_boards")
.insert({ org_id: org.id, name: newDocName })
.select()
.single();
if (boardError || !newBoard) {
toasts.error("Failed to create kanban board");
return;
}
await supabase.from("kanban_columns").insert([
{ board_id: newBoard.id, name: "To Do", position: 0 },
{ board_id: newBoard.id, name: "In Progress", position: 1 },
{ board_id: newBoard.id, name: "Done", position: 2 },
]);
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
id: newBoard.id,
org_id: org.id,
name: newDocName,
type: "kanban",
parent_id: currentFolderId,
created_by: user.id,
content: {
type: "kanban",
board_id: newBoard.id,
} as import("$lib/supabase/types").Json,
})
.select()
.single();
if (!error && newDoc) {
goto(getFileUrl(newDoc as Document));
} else if (error) {
toasts.error("Failed to create kanban document");
}
} else {
let content: any = null;
if (newDocType === "document") {
content = { type: "doc", content: [] };
}
const { data: newDoc, error } = await supabase
.from("documents")
.insert({
org_id: org.id,
name: newDocName,
type: newDocType as "folder" | "document",
parent_id: currentFolderId,
created_by: user.id,
content,
})
.select()
.single();
if (!error && newDoc) {
documents = [...documents, newDoc as Document];
if (newDocType === "document") {
goto(getFileUrl(newDoc as Document));
try {
if (newDocType === "kanban") {
// Create kanban board first, then link as document
const { data: newBoard, error: boardError } = await supabase
.from("kanban_boards")
.insert({ org_id: org.id, name: newDocName })
.select()
.single();
if (boardError || !newBoard) {
toasts.error("Failed to create kanban board");
return;
}
await supabase.from("kanban_columns").insert([
{ board_id: newBoard.id, name: "To Do", position: 0 },
{ board_id: newBoard.id, name: "In Progress", position: 1 },
{ board_id: newBoard.id, name: "Done", position: 2 },
]);
const newDoc = await createDocument(
supabase,
org.id,
newDocName,
"kanban",
currentFolderId,
user.id,
{
id: newBoard.id,
content: {
type: "kanban",
board_id: newBoard.id,
} as import("$lib/supabase/types").Json,
},
);
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "create",
entityType: "kanban_board",
entityId: newDoc.id,
entityName: newDocName,
});
goto(getFileUrl(newDoc));
} else {
const newDoc = await createDocument(
supabase,
org.id,
newDocName,
newDocType,
currentFolderId,
user.id,
);
documents = [...documents, newDoc];
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "create",
entityType: newDocType === "folder" ? "folder" : "document",
entityId: newDoc.id,
entityName: newDocName,
});
if (newDocType === "document") {
goto(getFileUrl(newDoc));
}
} else if (error) {
toasts.error("Failed to create document");
}
} catch {
toasts.error("Failed to create document");
}
showCreateModal = false;
@@ -389,28 +395,39 @@
async function handleSave(content: import("$lib/supabase/types").Json) {
if (!selectedDoc) return;
await supabase
.from("documents")
.update({ content, updated_at: new Date().toISOString() })
.eq("id", selectedDoc.id);
documents = documents.map((d) =>
d.id === selectedDoc!.id ? { ...d, content } : d,
);
try {
await updateDocument(supabase, selectedDoc.id, { content });
documents = documents.map((d) =>
d.id === selectedDoc!.id ? { ...d, content } : d,
);
} catch {
toasts.error("Failed to save document");
}
}
async function handleRename() {
if (!editingDoc || !newDocName.trim()) return;
const { error } = await supabase
.from("documents")
.update({ name: newDocName, updated_at: new Date().toISOString() })
.eq("id", editingDoc.id);
if (!error) {
try {
await updateDocument(supabase, editingDoc.id, { name: newDocName });
if (user) {
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "rename",
entityType:
editingDoc.type === "folder" ? "folder" : "document",
entityId: editingDoc.id,
entityName: newDocName,
});
}
documents = documents.map((d) =>
d.id === editingDoc!.id ? { ...d, name: newDocName } : d,
);
if (selectedDoc?.id === editingDoc.id) {
selectedDoc = { ...selectedDoc, name: newDocName };
}
} catch {
toasts.error("Failed to rename document");
}
showEditModal = false;
editingDoc = null;
@@ -435,21 +452,30 @@
return ids;
}
if (doc.type === "folder") {
const descendantIds = collectDescendantIds(doc.id);
if (descendantIds.length > 0) {
await supabase
.from("documents")
.delete()
.in("id", descendantIds);
try {
if (doc.type === "folder") {
const descendantIds = collectDescendantIds(doc.id);
for (const id of descendantIds) {
await deleteDocument(supabase, id);
}
}
}
await deleteDocument(supabase, doc.id);
const { error } = await supabase
.from("documents")
.delete()
.eq("id", doc.id);
if (!error) {
if (user) {
logActivity(supabase, {
orgId: org.id,
userId: user.id,
action: "delete",
entityType:
doc.type === "folder"
? "folder"
: doc.type === "kanban"
? "kanban_board"
: "document",
entityId: doc.id,
entityName: doc.name,
});
}
const deletedIds = new Set([
doc.id,
...(doc.type === "folder" ? collectDescendantIds(doc.id) : []),
@@ -458,6 +484,8 @@
if (selectedDoc?.id === doc.id) {
selectedDoc = null;
}
} catch {
toasts.error("Failed to delete document");
}
}
</script>
@@ -471,12 +499,8 @@
<header class="flex items-center gap-2 p-1">
<Avatar name={title} size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">{title}</h1>
<Button size="md" onclick={handleAdd}>+ New</Button>
<IconButton
title="Toggle view"
onclick={() =>
(viewMode = viewMode === "list" ? "grid" : "list")}
>
<Button size="md" onclick={handleAdd}>{m.btn_new()}</Button>
<IconButton title={m.files_toggle_view()} onclick={toggleViewMode}>
<Icon
name={viewMode === "list" ? "grid_view" : "view_list"}
size={24}
@@ -668,7 +692,7 @@
<Modal
isOpen={showCreateModal}
onClose={() => (showCreateModal = false)}
title="Create New"
title={m.files_create_title()}
>
<div class="space-y-4">
<div class="flex gap-2">
@@ -685,7 +709,7 @@
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>description</span
>
Document
{m.files_type_document()}
</button>
<button
type="button"
@@ -700,7 +724,7 @@
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>folder</span
>
Folder
{m.files_type_folder()}
</button>
<button
type="button"
@@ -715,24 +739,24 @@
style="font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>view_kanban</span
>
Kanban
{m.files_type_kanban()}
</button>
</div>
<Input
label="Name"
label={m.files_name_label()}
bind:value={newDocName}
placeholder={newDocType === "folder"
? "Folder name"
? m.files_folder_placeholder()
: newDocType === "kanban"
? "Kanban board name"
: "Document name"}
? m.files_kanban_placeholder()
: m.files_doc_placeholder()}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
>Cancel</Button
>{m.btn_cancel()}</Button
>
<Button onclick={handleCreate} disabled={!newDocName.trim()}
>Create</Button
>{m.btn_create()}</Button
>
</div>
</div>
@@ -757,7 +781,7 @@
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>edit</span
>
Rename
{m.files_context_rename()}
</button>
<button
type="button"
@@ -837,7 +861,7 @@
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>delete</span
>
Delete
{m.files_context_delete()}
</button>
</div>
{/if}
@@ -849,13 +873,13 @@
editingDoc = null;
newDocName = "";
}}
title="Rename"
title={m.files_rename_title()}
>
<div class="space-y-4">
<Input
label="Name"
label={m.files_name_label()}
bind:value={newDocName}
placeholder="Enter new name"
placeholder={m.files_name_label()}
/>
<div class="flex justify-end gap-2 pt-2">
<Button
@@ -864,10 +888,10 @@
showEditModal = false;
editingDoc = null;
newDocName = "";
}}>Cancel</Button
}}>{m.btn_cancel()}</Button
>
<Button onclick={handleRename} disabled={!newDocName.trim()}
>Save</Button
>{m.btn_save()}</Button
>
</div>
</div>

View File

@@ -100,6 +100,10 @@
let cardTagIds = $state<Set<string>>(new Set());
let newTagName = $state("");
let showTagInput = $state(false);
let editingTagId = $state<string | null>(null);
let editTagName = $state("");
let editTagColor = $state("");
let showTagManager = $state(false);
const TAG_COLORS = [
"#00A3E0",
@@ -238,6 +242,38 @@
showTagInput = false;
}
function startEditTag(tag: OrgTag) {
editingTagId = tag.id;
editTagName = tag.name;
editTagColor = tag.color || TAG_COLORS[0];
}
async function saveEditTag() {
if (!editingTagId || !editTagName.trim()) return;
const { error } = await supabase
.from("tags")
.update({ name: editTagName.trim(), color: editTagColor })
.eq("id", editingTagId);
if (!error) {
orgTags = orgTags.map((t) =>
t.id === editingTagId
? { ...t, name: editTagName.trim(), color: editTagColor }
: t,
);
}
editingTagId = null;
}
async function deleteTag(tagId: string) {
if (!confirm("Delete this tag from the organization?")) return;
const { error } = await supabase.from("tags").delete().eq("id", tagId);
if (!error) {
orgTags = orgTags.filter((t) => t.id !== tagId);
cardTagIds.delete(tagId);
cardTagIds = new Set(cardTagIds);
}
}
async function handleSave() {
if (!isMounted) return;
if (mode === "create") {
@@ -282,7 +318,10 @@
.eq("id", columnId)
.single();
const position = (column as any)?.cards?.[0]?.count ?? 0; // join aggregation not typed
const cards = (column as Record<string, unknown> | null)?.cards as
| { count: number }[]
| undefined;
const position = cards?.[0]?.count ?? 0;
const { data: newCard, error } = await supabase
.from("kanban_cards")
@@ -300,6 +339,26 @@
.single();
if (!error && newCard) {
// Persist checklist items added during creation
if (checklist.length > 0) {
await supabase.from("kanban_checklist_items").insert(
checklist.map((item, i) => ({
card_id: newCard.id,
title: item.title,
position: i,
completed: false,
})),
);
}
// Persist tags assigned during creation
if (cardTagIds.size > 0) {
await supabase.from("card_tags").insert(
[...cardTagIds].map((tagId) => ({
card_id: newCard.id,
tag_id: tagId,
})),
);
}
onCreate?.(newCard as KanbanCard);
onClose();
}
@@ -307,7 +366,25 @@
}
async function handleAddItem() {
if (!card || !newItemTitle.trim()) return;
if (!newItemTitle.trim()) return;
if (mode === "create") {
// In create mode, add items locally (no card ID yet)
checklist = [
...checklist,
{
id: `temp-${Date.now()}`,
card_id: "",
title: newItemTitle.trim(),
completed: false,
position: checklist.length,
},
];
newItemTitle = "";
return;
}
if (!card) return;
const position = checklist.length;
const { data, error } = await supabase
@@ -429,10 +506,118 @@
<!-- Tags -->
<div>
<span
class="px-3 font-bold font-body text-body text-white mb-2 block"
>Tags</span
>
<div class="flex items-center justify-between mb-2">
<span class="px-3 font-bold font-body text-body text-white"
>Tags</span
>
<button
type="button"
class="text-xs text-light/40 hover:text-light transition-colors"
onclick={() => (showTagManager = !showTagManager)}
>
{showTagManager ? "Done" : "Manage"}
</button>
</div>
{#if showTagManager}
<!-- Tag Manager: edit/delete/create tags -->
<div class="space-y-2 mb-3 p-3 bg-background rounded-2xl">
{#each orgTags as tag}
<div class="flex items-center gap-2 group">
{#if editingTagId === tag.id}
<div class="flex items-center gap-2 flex-1">
<input
type="color"
class="w-6 h-6 rounded cursor-pointer border-0 bg-transparent"
bind:value={editTagColor}
/>
<input
type="text"
class="bg-dark border border-primary rounded-lg px-2 py-1 text-sm text-white flex-1 focus:outline-none"
bind:value={editTagName}
onkeydown={(e) => {
if (e.key === "Enter")
saveEditTag();
if (e.key === "Escape") {
editingTagId = null;
}
}}
/>
<button
type="button"
class="text-primary text-xs font-bold"
onclick={saveEditTag}>Save</button
>
<button
type="button"
class="text-light/40 text-xs"
onclick={() =>
(editingTagId = null)}
>Cancel</button
>
</div>
{:else}
<span
class="w-3 h-3 rounded-sm shrink-0"
style="background-color: {tag.color ||
'#00A3E0'}"
></span>
<span
class="text-sm text-light flex-1 truncate"
>{tag.name}</span
>
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-light transition-all"
onclick={() => startEditTag(tag)}
aria-label="Edit tag"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>edit</span
>
</button>
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-error transition-all"
onclick={() => deleteTag(tag.id)}
aria-label="Delete tag"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>delete</span
>
</button>
{/if}
</div>
{/each}
<!-- Inline create new tag -->
<div
class="flex items-center gap-2 pt-1 border-t border-light/10"
>
<input
type="text"
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white flex-1 focus:outline-none focus:border-primary"
placeholder="New tag name..."
bind:value={newTagName}
onkeydown={(e) =>
e.key === "Enter" && createTag()}
/>
<button
type="button"
class="text-primary text-xs font-bold hover:text-primary/80 whitespace-nowrap"
onclick={createTag}
disabled={!newTagName.trim()}
>
+ Add
</button>
</div>
</div>
{/if}
<!-- Tag toggle chips -->
<div class="flex flex-wrap gap-2 items-center">
{#each orgTags as tag}
<button
@@ -450,42 +635,44 @@
{tag.name}
</button>
{/each}
{#if showTagInput}
<div class="flex gap-1 items-center">
<input
type="text"
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white w-24 focus:outline-none focus:border-primary"
placeholder="Tag name"
bind:value={newTagName}
onkeydown={(e) =>
e.key === "Enter" && createTag()}
/>
{#if !showTagManager}
{#if showTagInput}
<div class="flex gap-1 items-center">
<input
type="text"
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white w-24 focus:outline-none focus:border-primary"
placeholder="Tag name"
bind:value={newTagName}
onkeydown={(e) =>
e.key === "Enter" && createTag()}
/>
<button
type="button"
class="text-primary text-sm font-bold hover:text-primary/80"
onclick={createTag}
>
Add
</button>
<button
type="button"
class="text-light/40 text-sm hover:text-light"
onclick={() => {
showTagInput = false;
newTagName = "";
}}
>
Cancel
</button>
</div>
{:else}
<button
type="button"
class="text-primary text-sm font-bold hover:text-primary/80"
onclick={createTag}
class="rounded-lg px-2 py-1 text-sm text-light/50 hover:text-light border border-dashed border-light/20 hover:border-light/40 transition-colors"
onclick={() => (showTagInput = true)}
>
Add
+ New tag
</button>
<button
type="button"
class="text-light/40 text-sm hover:text-light"
onclick={() => {
showTagInput = false;
newTagName = "";
}}
>
Cancel
</button>
</div>
{:else}
<button
type="button"
class="rounded-lg px-2 py-1 text-sm text-light/50 hover:text-light border border-dashed border-light/20 hover:border-light/40 transition-colors"
onclick={() => (showTagInput = true)}
>
+ New tag
</button>
{/if}
{/if}
</div>
</div>

View File

@@ -2,6 +2,7 @@
import type { ColumnWithCards } from "$lib/api/kanban";
import type { KanbanCard } from "$lib/supabase/types";
import KanbanCardComponent from "./KanbanCard.svelte";
import { Button } from "$lib/components/ui";
interface Props {
columns: ColumnWithCards[];
@@ -15,6 +16,7 @@
onAddColumn?: () => void;
onDeleteCard?: (cardId: string) => void;
onDeleteColumn?: (columnId: string) => void;
onRenameColumn?: (columnId: string, newName: string) => void;
canEdit?: boolean;
}
@@ -26,9 +28,37 @@
onAddColumn,
onDeleteCard,
onDeleteColumn,
onRenameColumn,
canEdit = true,
}: Props = $props();
let columnMenuId = $state<string | null>(null);
let renamingColumnId = $state<string | null>(null);
let renameValue = $state("");
function openColumnMenu(columnId: string) {
columnMenuId = columnMenuId === columnId ? null : columnId;
}
function startRename(column: ColumnWithCards) {
renameValue = column.name;
renamingColumnId = column.id;
columnMenuId = null;
}
function confirmRename() {
if (renamingColumnId && renameValue.trim()) {
onRenameColumn?.(renamingColumnId, renameValue.trim());
}
renamingColumnId = null;
renameValue = "";
}
function cancelRename() {
renamingColumnId = null;
renameValue = "";
}
let draggedCard = $state<KanbanCard | null>(null);
let dragOverColumn = $state<string | null>(null);
let dragOverCardIndex = $state<{ columnId: string; index: number } | null>(
@@ -58,7 +88,6 @@
e.stopPropagation();
if (!draggedCard) return;
// Determine if we're in the top or bottom half of the card
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const dropIndex = e.clientY < midY ? index : index + 1;
@@ -84,7 +113,6 @@
let newPosition: number;
if (targetIndex && targetIndex.columnId === columnId) {
newPosition = targetIndex.index;
// If moving within the same column and the card is above the target, adjust
if (draggedCard.column_id === columnId) {
const currentIndex = column.cards.findIndex(
(c) => c.id === draggedCard!.id,
@@ -92,7 +120,6 @@
if (currentIndex !== -1 && currentIndex < newPosition) {
newPosition = Math.max(0, newPosition - 1);
}
// No-op if dropping in the same position
if (currentIndex === newPosition) {
draggedCard = null;
return;
@@ -107,10 +134,13 @@
}
</script>
<div class="flex gap-2 overflow-x-auto pb-4 h-full kanban-scroll">
{#each columns as column}
<div
class="flex gap-2 overflow-x-auto pb-4 h-full kanban-scroll"
role="presentation"
>
{#each columns as column, colIndex (column.id)}
<div
class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4 max-h-full {dragOverColumn ===
class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4 max-h-full transition-opacity {dragOverColumn ===
column.id
? 'ring-2 ring-primary'
: ''}"
@@ -120,11 +150,25 @@
role="list"
>
<!-- Column Header -->
<div class="flex items-center gap-2 p-1 rounded-[32px]">
<div class="flex items-center gap-1 p-1 rounded-[32px]">
<div class="flex items-center gap-2 flex-1 min-w-0">
<h3 class="font-heading text-h4 text-white truncate">
{column.name}
</h3>
{#if renamingColumnId === column.id}
<input
type="text"
class="bg-dark border border-primary rounded-lg px-2 py-1 text-white font-heading text-h4 w-full focus:outline-none"
bind:value={renameValue}
onkeydown={(e) => {
if (e.key === "Enter") confirmRename();
if (e.key === "Escape") cancelRename();
}}
onblur={confirmRename}
autofocus
/>
{:else}
<h3 class="font-heading text-h4 text-white truncate">
{column.name}
</h3>
{/if}
<div
class="bg-dark flex items-center justify-center px-1.5 py-0.5 rounded-[8px] shrink-0"
>
@@ -133,19 +177,62 @@
>
</div>
</div>
<button
type="button"
class="p-1 hover:bg-night rounded-lg transition-colors shrink-0"
onclick={() => onDeleteColumn?.(column.id)}
aria-label="Column options"
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
more_horiz
</span>
</button>
{#if canEdit}
<div class="relative shrink-0">
<button
type="button"
class="p-1 hover:bg-night rounded-full transition-colors"
onclick={() => openColumnMenu(column.id)}
aria-label="Column options"
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
more_horiz
</span>
</button>
{#if columnMenuId === column.id}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-40"
onclick={() => (columnMenuId = null)}
></div>
<div
class="absolute right-0 top-full mt-1 bg-night border border-light/10 rounded-2xl shadow-xl z-50 py-1 min-w-[160px]"
>
<button
type="button"
class="w-full px-4 py-2.5 text-left text-sm text-white hover:bg-dark transition-colors flex items-center gap-3"
onclick={() => startRename(column)}
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>edit</span
>
Rename
</button>
<button
type="button"
class="w-full px-4 py-2.5 text-left text-sm text-error hover:bg-dark transition-colors flex items-center gap-3"
onclick={() => {
columnMenuId = null;
onDeleteColumn?.(column.id);
}}
>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>delete</span
>
Delete
</button>
</div>
{/if}
</div>
{/if}
</div>
<!-- Cards -->
@@ -182,34 +269,31 @@
{/if}
</div>
<!-- Add Card Button (secondary style) -->
<!-- Add Card Button -->
{#if canEdit}
<button
type="button"
class="w-full py-3 border-[3px] border-primary text-primary font-heading text-h5 rounded-[32px] hover:bg-primary/10 transition-colors"
<Button
variant="secondary"
fullWidth
icon="add"
onclick={() => onAddCard?.(column.id)}
>
Add card
</button>
</Button>
{/if}
</div>
{/each}
<!-- Add Column Button -->
{#if canEdit}
<button
type="button"
class="flex-shrink-0 w-[256px] h-12 border-[3px] border-primary/30 hover:border-primary rounded-[32px] flex items-center justify-center gap-2 text-primary/50 hover:text-primary transition-colors"
onclick={() => onAddColumn?.()}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
<div class="flex-shrink-0 w-[256px]">
<Button
variant="secondary"
fullWidth
icon="add"
onclick={() => onAddColumn?.()}
>
add
</span>
Add column
</button>
Add column
</Button>
</div>
{/if}
</div>

View File

@@ -67,7 +67,7 @@
{#if ondelete}
<button
type="button"
class="absolute top-1 right-1 p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/20 transition-all z-10"
class="absolute top-1 right-1 p-1 rounded-full opacity-0 group-hover:opacity-100 hover:bg-error/20 transition-all z-10"
onclick={handleDelete}
aria-label="Delete card"
>
@@ -95,7 +95,7 @@
{/if}
<!-- Title -->
<p class="font-body text-body text-white w-full leading-none">
<p class="font-body text-body text-white w-full leading-none p-1">
{card.title}
</p>

View File

@@ -0,0 +1,369 @@
<script lang="ts">
import { Button, Modal, Card, Input } from "$lib/components/ui";
import { toasts } from "$lib/stores/toast.svelte";
import {
extractCalendarId,
getCalendarSubscribeUrl,
} from "$lib/api/google-calendar";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import * as m from "$lib/paraglide/messages";
interface OrgCalendar {
id: string;
org_id: string;
calendar_id: string;
calendar_name: string | null;
}
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
userId: string;
orgCalendar: OrgCalendar | null;
initialShowConnect?: boolean;
serviceAccountEmail?: string | null;
}
let {
supabase,
orgId,
userId,
orgCalendar = $bindable(),
initialShowConnect = false,
serviceAccountEmail = null,
}: Props = $props();
let emailCopied = $state(false);
async function copyServiceEmail() {
if (!serviceAccountEmail) return;
await navigator.clipboard.writeText(serviceAccountEmail);
emailCopied = true;
setTimeout(() => (emailCopied = false), 2000);
}
let showConnectModal = $state(initialShowConnect);
let isLoading = $state(false);
let calendarUrlInput = $state("");
let calendarError = $state<string | null>(null);
async function handleSaveOrgCalendar() {
if (!calendarUrlInput.trim()) return;
isLoading = true;
calendarError = null;
const calendarId = extractCalendarId(calendarUrlInput.trim());
if (!calendarId) {
calendarError =
"Invalid calendar URL or ID. Please paste a Google Calendar share URL or calendar ID.";
isLoading = false;
return;
}
let calendarName = "Google Calendar";
if (calendarId.includes("@group.calendar.google.com")) {
calendarName = "Shared Calendar";
} else if (calendarId.includes("@gmail.com")) {
calendarName = calendarId.split("@")[0] + "'s Calendar";
}
const { data: newCal, error } = await supabase
.from("org_google_calendars")
.upsert(
{
org_id: orgId,
calendar_id: calendarId,
calendar_name: calendarName,
connected_by: userId,
},
{ onConflict: "org_id" },
)
.select()
.single();
if (error) {
calendarError = "Failed to save calendar.";
} else if (newCal) {
orgCalendar = newCal as OrgCalendar;
calendarUrlInput = "";
}
showConnectModal = false;
isLoading = false;
}
async function disconnectOrgCalendar() {
if (!confirm("Disconnect Google Calendar?")) return;
const { error } = await supabase
.from("org_google_calendars")
.delete()
.eq("org_id", orgId);
if (error) {
toasts.error(m.toast_error_disconnect_cal());
return;
}
orgCalendar = null;
}
</script>
<div class="space-y-6 max-w-2xl">
<Card>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="w-12 h-12 bg-white rounded-lg flex items-center justify-center"
>
<svg class="w-8 h-8" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-light">
Google Calendar
</h3>
<p class="text-sm text-light/50 mt-1">
Sync events between your organization and Google
Calendar.
</p>
{#if orgCalendar}
<div
class="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg"
>
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3 bg-green-500/10 rounded-lg"
>
<div class="min-w-0 flex-1">
<p
class="text-sm font-medium text-green-400"
>
Connected
</p>
<p class="text-light font-medium">
{orgCalendar.calendar_name ||
"Google Calendar"}
</p>
<p
class="text-xs text-light/50 truncate"
title={orgCalendar.calendar_id}
>
{orgCalendar.calendar_id}
</p>
<p class="text-xs text-light/40 mt-1">
Events sync both ways — create here or
in Google Calendar.
</p>
<a
href="https://calendar.google.com/calendar/u/0/r?cid={encodeURIComponent(
orgCalendar.calendar_id,
)}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300 mt-2"
>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
/>
<polyline points="15 3 21 3 21 9" />
<line
x1="10"
y1="14"
x2="21"
y2="3"
/>
</svg>
Open in Google Calendar
</a>
</div>
<Button
variant="danger"
size="sm"
onclick={disconnectOrgCalendar}
>Disconnect</Button
>
</div>
</div>
{:else if !serviceAccountEmail}
<div
class="mt-4 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
>
<p class="text-sm text-yellow-400 font-medium">
Setup required
</p>
<p class="text-xs text-light/50 mt-1">
A server administrator needs to configure the <code
class="bg-light/10 px-1 rounded"
>GOOGLE_SERVICE_ACCOUNT_KEY</code
> environment variable before calendars can be connected.
</p>
</div>
{:else}
<div class="mt-4">
<Button onclick={() => (showConnectModal = true)}
>Connect Google Calendar</Button
>
</div>
{/if}
</div>
</div>
</div>
</Card>
<Card>
<div class="p-6 opacity-50">
<div class="flex items-start gap-4">
<div
class="w-12 h-12 bg-[#7289da] rounded-lg flex items-center justify-center"
>
<svg
class="w-7 h-7 text-white"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-light">Discord</h3>
<p class="text-sm text-light/50 mt-1">
Get notifications in your Discord server.
</p>
<p class="text-xs text-light/40 mt-2">Coming soon</p>
</div>
</div>
</div>
</Card>
<Card>
<div class="p-6 opacity-50">
<div class="flex items-start gap-4">
<div
class="w-12 h-12 bg-[#4A154B] rounded-lg flex items-center justify-center"
>
<svg
class="w-7 h-7 text-white"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52a2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521a2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521a2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523a2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"
/>
</svg>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-light">Slack</h3>
<p class="text-sm text-light/50 mt-1">
Get notifications in your Slack workspace.
</p>
<p class="text-xs text-light/40 mt-2">Coming soon</p>
</div>
</div>
</div>
</Card>
</div>
<!-- Connect Calendar Modal -->
<Modal
isOpen={showConnectModal}
onClose={() => (showConnectModal = false)}
title="Connect Google Calendar"
>
<div class="space-y-4">
<p class="text-sm text-light/70">
Connect any Google Calendar to your organization. Events you create
here will automatically appear in Google Calendar and vice versa.
</p>
<!-- Step 1: Share with service account -->
{#if serviceAccountEmail}
<div
class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg"
>
<p class="text-blue-400 font-medium text-sm mb-2">
Step 1: Share your calendar
</p>
<p class="text-xs text-light/60 mb-2">
In Google Calendar, go to your calendar's settings → "Share
with specific people" → add this email with <strong
>"Make changes to events"</strong
> permission:
</p>
<div class="flex items-center gap-2">
<code
class="flex-1 text-xs bg-light/10 px-3 py-2 rounded-lg text-light/80 truncate"
title={serviceAccountEmail}
>
{serviceAccountEmail}
</code>
<Button
size="sm"
variant="tertiary"
onclick={copyServiceEmail}
>
{emailCopied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
{/if}
<!-- Step 2: Paste calendar ID -->
<div class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p class="text-blue-400 font-medium text-sm mb-2">
{serviceAccountEmail ? "Step 2" : "Step 1"}: Paste your Calendar
ID
</p>
<p class="text-xs text-light/60 mb-2">
In your calendar settings, scroll to "Integrate calendar" and
copy the <strong>Calendar ID</strong>.
</p>
</div>
<Input
label="Calendar ID"
bind:value={calendarUrlInput}
placeholder="e.g. abc123@group.calendar.google.com"
/>
{#if calendarError}
<p class="text-red-400 text-sm">{calendarError}</p>
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button
variant="tertiary"
onclick={() => (showConnectModal = false)}>Cancel</Button
>
<Button
onclick={handleSaveOrgCalendar}
loading={isLoading}
disabled={!calendarUrlInput.trim()}>Connect</Button
>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,398 @@
<script lang="ts">
import {
Button,
Modal,
Card,
Input,
Select,
Avatar,
} 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";
const INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
interface ProfileData {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
}
interface Member {
id: string;
user_id: string;
role: string;
role_id: string | null;
invited_at: string;
profiles: ProfileData | ProfileData[] | null;
}
interface OrgRole {
id: string;
org_id: string;
name: string;
color: string;
permissions: string[];
is_default: boolean;
is_system: boolean;
position: number;
}
interface Invite {
id: string;
email: string;
role: string;
role_id: string | null;
token: string;
expires_at: string;
created_at: string;
}
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
userId: string;
members: Member[];
roles: OrgRole[];
invites: Invite[];
}
let {
supabase,
orgId,
userId,
members = $bindable(),
roles,
invites = $bindable(),
}: Props = $props();
let showInviteModal = $state(false);
let inviteEmail = $state("");
let inviteRole = $state("editor");
let isSendingInvite = $state(false);
let showMemberModal = $state(false);
let selectedMember = $state<Member | null>(null);
let selectedMemberRole = $state("");
async function sendInvite() {
if (!inviteEmail.trim()) return;
isSendingInvite = true;
const email = inviteEmail.toLowerCase().trim();
// Delete any existing invite for this email first (handles 409 conflict)
await supabase
.from("org_invites")
.delete()
.eq("org_id", orgId)
.eq("email", email);
const { data: invite, error } = await supabase
.from("org_invites")
.insert({
org_id: orgId,
email,
role: inviteRole,
invited_by: userId,
expires_at: new Date(
Date.now() + INVITE_EXPIRY_MS,
).toISOString(),
})
.select()
.single();
if (!error && invite) {
invites = invites.filter((i) => i.email !== email);
invites = [...invites, invite as Invite];
inviteEmail = "";
showInviteModal = false;
} else if (error) {
toasts.error(m.toast_error_invite({ error: error.message }));
}
isSendingInvite = false;
}
async function cancelInvite(inviteId: string) {
await supabase.from("org_invites").delete().eq("id", inviteId);
invites = invites.filter((i) => i.id !== inviteId);
}
function openMemberModal(member: Member) {
selectedMember = member;
selectedMemberRole = member.role;
showMemberModal = true;
}
async function updateMemberRole() {
if (!selectedMember) return;
const { error } = await supabase
.from("org_members")
.update({ role: selectedMemberRole })
.eq("id", selectedMember.id);
if (error) {
toasts.error(m.toast_error_update_role());
return;
}
members = members.map((m) =>
m.id === selectedMember!.id
? { ...m, role: selectedMemberRole }
: m,
);
showMemberModal = false;
}
async function removeMember() {
if (!selectedMember) return;
const rp = selectedMember.profiles;
const prof = Array.isArray(rp) ? rp[0] : rp;
if (
!confirm(
`Remove ${prof?.full_name || prof?.email || "this member"} from the organization?`,
)
)
return;
const { error } = await supabase
.from("org_members")
.delete()
.eq("id", selectedMember.id);
if (error) {
toasts.error(m.toast_error_remove_member());
return;
}
members = members.filter((m) => m.id !== selectedMember!.id);
showMemberModal = false;
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-light">
{m.settings_members_title({
count: String(members.length),
})}
</h2>
<Button onclick={() => (showInviteModal = true)}>
<svg
class="w-4 h-4 mr-2"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><line x1="19" y1="8" x2="19" y2="14" /><line
x1="22"
y1="11"
x2="16"
y2="11"
/>
</svg>
{m.settings_members_invite()}
</Button>
</div>
<!-- Pending Invites -->
{#if invites.length > 0}
<Card>
<div class="p-4">
<h3 class="text-sm font-medium text-light/70 mb-3">
{m.settings_members_pending()}
</h3>
<div class="space-y-2">
{#each invites as invite}
<div
class="flex items-center justify-between py-2 px-3 bg-light/5 rounded-lg"
>
<div>
<p class="text-light">{invite.email}</p>
<p class="text-xs text-light/40">
Invited as {invite.role} • Expires {new Date(
invite.expires_at,
).toLocaleDateString()}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="tertiary"
size="sm"
onclick={() =>
navigator.clipboard.writeText(
`${window.location.origin}/invite/${invite.token}`,
)}
>{m.settings_members_copy_link()}</Button
>
<Button
variant="danger"
size="sm"
onclick={() => cancelInvite(invite.id)}
>Cancel</Button
>
</div>
</div>
{/each}
</div>
</div>
</Card>
{/if}
<!-- Members List -->
<Card>
<div class="divide-y divide-light/10">
{#each members as member}
{@const rawProfile = member.profiles}
{@const profile = Array.isArray(rawProfile)
? rawProfile[0]
: rawProfile}
<div
class="flex items-center justify-between p-4 hover:bg-light/5 transition-colors"
>
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
>
{(profile?.full_name ||
profile?.email ||
"?")[0].toUpperCase()}
</div>
<div>
<p class="text-light font-medium">
{profile?.full_name ||
profile?.email ||
"Unknown User"}
</p>
<p class="text-sm text-light/50">
{profile?.email || "No email"}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span
class="px-2 py-1 text-xs rounded-full capitalize"
style="background-color: {roles.find(
(r) => r.name.toLowerCase() === member.role,
)?.color ?? '#6366f1'}20; color: {roles.find(
(r) => r.name.toLowerCase() === member.role,
)?.color ?? '#6366f1'}">{member.role}</span
>
{#if member.user_id !== userId && member.role !== "owner"}
<Button
variant="tertiary"
size="sm"
onclick={() => openMemberModal(member)}
>Edit</Button
>
{/if}
</div>
</div>
{/each}
</div>
</Card>
</div>
<!-- Invite Member Modal -->
<Modal
isOpen={showInviteModal}
onClose={() => (showInviteModal = false)}
title="Invite Member"
>
<div class="space-y-4">
<Input
type="email"
label="Email address"
bind:value={inviteEmail}
placeholder="colleague@example.com"
/>
<Select
label="Role"
bind:value={inviteRole}
placeholder=""
options={[
{ value: "viewer", label: "Viewer - Can view content" },
{
value: "commenter",
label: "Commenter - Can view and comment",
},
{
value: "editor",
label: "Editor - Can create and edit content",
},
{
value: "admin",
label: "Admin - Can manage members and settings",
},
]}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showInviteModal = false)}
>Cancel</Button
>
<Button
onclick={sendInvite}
loading={isSendingInvite}
disabled={!inviteEmail.trim()}>Send Invite</Button
>
</div>
</div>
</Modal>
<!-- Edit Member Modal -->
<Modal
isOpen={showMemberModal}
onClose={() => (showMemberModal = false)}
title="Edit Member"
>
{#if selectedMember}
{@const rawP = selectedMember.profiles}
{@const memberProfile = Array.isArray(rawP) ? rawP[0] : rawP}
<div class="space-y-4">
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg">
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
>
{(memberProfile?.full_name ||
memberProfile?.email ||
"?")[0].toUpperCase()}
</div>
<div>
<p class="text-light font-medium">
{memberProfile?.full_name || "No name"}
</p>
<p class="text-sm text-light/50">
{memberProfile?.email || "No email"}
</p>
</div>
</div>
<Select
label="Role"
bind:value={selectedMemberRole}
placeholder=""
options={[
{ value: "viewer", label: "Viewer" },
{ value: "commenter", label: "Commenter" },
{ value: "editor", label: "Editor" },
{ value: "admin", label: "Admin" },
]}
/>
<div class="flex items-center justify-between pt-2">
<Button variant="danger" onclick={removeMember}
>Remove from Org</Button
>
<div class="flex gap-2">
<Button
variant="tertiary"
onclick={() => (showMemberModal = false)}>Cancel</Button
>
<Button onclick={updateMemberRole}>Save</Button>
</div>
</div>
</div>
{/if}
</Modal>

View File

@@ -0,0 +1,350 @@
<script lang="ts">
import { Button, Modal, Card, Input } 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";
interface OrgRole {
id: string;
org_id: string;
name: string;
color: string;
permissions: string[];
is_default: boolean;
is_system: boolean;
position: number;
}
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
roles: OrgRole[];
}
let { supabase, orgId, roles = $bindable() }: Props = $props();
let showRoleModal = $state(false);
let editingRole = $state<OrgRole | null>(null);
let newRoleName = $state("");
let newRoleColor = $state("#6366f1");
let newRolePermissions = $state<string[]>([]);
let isSavingRole = $state(false);
const permissionGroups = [
{
name: "Documents",
permissions: [
"documents.view",
"documents.create",
"documents.edit",
"documents.delete",
],
},
{
name: "Kanban",
permissions: [
"kanban.view",
"kanban.create",
"kanban.edit",
"kanban.delete",
],
},
{
name: "Calendar",
permissions: [
"calendar.view",
"calendar.create",
"calendar.edit",
"calendar.delete",
],
},
{
name: "Members",
permissions: [
"members.view",
"members.invite",
"members.manage",
"members.remove",
],
},
{
name: "Roles",
permissions: [
"roles.view",
"roles.create",
"roles.edit",
"roles.delete",
],
},
{ name: "Settings", permissions: ["settings.view", "settings.edit"] },
];
const roleColors = [
{ value: "#ef4444", label: "Red" },
{ value: "#f59e0b", label: "Amber" },
{ value: "#10b981", label: "Emerald" },
{ value: "#3b82f6", label: "Blue" },
{ value: "#6366f1", label: "Indigo" },
{ value: "#8b5cf6", label: "Violet" },
{ value: "#ec4899", label: "Pink" },
{ value: "#6b7280", label: "Gray" },
];
function openRoleModal(role?: OrgRole) {
if (role) {
editingRole = role;
newRoleName = role.name;
newRoleColor = role.color;
newRolePermissions = [...role.permissions];
} else {
editingRole = null;
newRoleName = "";
newRoleColor = "#6366f1";
newRolePermissions = [
"documents.view",
"kanban.view",
"calendar.view",
"members.view",
];
}
showRoleModal = true;
}
async function saveRole() {
if (!newRoleName.trim()) return;
isSavingRole = true;
if (editingRole) {
const { error } = await supabase
.from("org_roles")
.update({
name: newRoleName,
color: newRoleColor,
permissions: newRolePermissions,
})
.eq("id", editingRole.id);
if (!error) {
roles = roles.map((r) =>
r.id === editingRole!.id
? {
...r,
name: newRoleName,
color: newRoleColor,
permissions: newRolePermissions,
}
: r,
);
}
} else {
const { data: role, error } = await supabase
.from("org_roles")
.insert({
org_id: orgId,
name: newRoleName,
color: newRoleColor,
permissions: newRolePermissions,
position: roles.length,
})
.select()
.single();
if (!error && role) {
roles = [...roles, role as OrgRole];
}
}
showRoleModal = false;
isSavingRole = false;
}
async function deleteRole(role: OrgRole) {
if (role.is_system) return;
if (
!confirm(
`Delete role "${role.name}"? Members with this role will need to be reassigned.`,
)
)
return;
const { error } = await supabase
.from("org_roles")
.delete()
.eq("id", role.id);
if (error) {
toasts.error(m.toast_error_delete_role());
return;
}
roles = roles.filter((r) => r.id !== role.id);
}
function togglePermission(perm: string) {
if (newRolePermissions.includes(perm)) {
newRolePermissions = newRolePermissions.filter((p) => p !== perm);
} else {
newRolePermissions = [...newRolePermissions, perm];
}
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-light">Roles</h2>
<p class="text-sm text-light/50">
Create custom roles with specific permissions.
</p>
</div>
<Button onclick={() => openRoleModal()} icon="add">
Create Role
</Button>
</div>
<div class="grid gap-4">
{#each roles as role}
<Card>
<div class="p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<div
class="w-3 h-3 rounded-full"
style="background-color: {role.color}"
></div>
<span class="font-medium text-light"
>{role.name}</span
>
{#if role.is_system}
<span
class="text-xs text-light/40 bg-light/10 px-2 py-0.5 rounded"
>System</span
>
{/if}
{#if role.is_default}
<span
class="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded"
>Default</span
>
{/if}
</div>
<div class="flex items-center gap-2">
{#if !role.is_system || role.name !== "Owner"}
<Button
variant="tertiary"
size="sm"
onclick={() => openRoleModal(role)}
>Edit</Button
>
{/if}
{#if !role.is_system}
<Button
variant="danger"
size="sm"
onclick={() => deleteRole(role)}
>Delete</Button
>
{/if}
</div>
</div>
<div class="flex flex-wrap gap-1">
{#if role.permissions.includes("*")}
<span
class="text-xs bg-light/10 text-light/70 px-2 py-1 rounded"
>All Permissions</span
>
{:else}
{#each role.permissions.slice(0, 6) as perm}
<span
class="text-xs bg-light/10 text-light/50 px-2 py-1 rounded"
>{perm}</span
>
{/each}
{#if role.permissions.length > 6}
<span class="text-xs text-light/40"
>+{role.permissions.length - 6} more</span
>
{/if}
{/if}
</div>
</div>
</Card>
{/each}
</div>
</div>
<!-- Edit/Create Role Modal -->
<Modal
isOpen={showRoleModal}
onClose={() => (showRoleModal = false)}
title={editingRole ? "Edit Role" : "Create Role"}
>
<div class="space-y-4">
<Input
label="Name"
bind:value={newRoleName}
placeholder="e.g., Moderator"
disabled={editingRole?.is_system}
/>
<div>
<label class="block text-sm font-medium text-light mb-2"
>Color</label
>
<div class="flex gap-2">
{#each roleColors as color}
<button
type="button"
class="w-8 h-8 rounded-full transition-transform {newRoleColor ===
color.value
? 'ring-2 ring-white scale-110'
: ''}"
style="background-color: {color.value}"
onclick={() => (newRoleColor = color.value)}
title={color.label}
></button>
{/each}
</div>
</div>
<div>
<label class="block text-sm font-medium text-light mb-2"
>Permissions</label
>
<div class="space-y-3 max-h-64 overflow-y-auto">
{#each permissionGroups as group}
<div class="p-3 bg-light/5 rounded-lg">
<p class="text-sm font-medium text-light mb-2">
{group.name}
</p>
<div class="grid grid-cols-2 gap-2">
{#each group.permissions as perm}
<label
class="flex items-center gap-2 text-sm text-light/70 cursor-pointer"
>
<input
type="checkbox"
checked={newRolePermissions.includes(
perm,
)}
onchange={() => togglePermission(perm)}
class="rounded"
/>
{perm.split(".")[1]}
</label>
{/each}
</div>
</div>
{/each}
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showRoleModal = false)}
>Cancel</Button
>
<Button
onclick={saveRole}
loading={isSavingRole}
disabled={!newRoleName.trim()}
>{editingRole ? "Save" : "Create"}</Button
>
</div>
</div>
</Modal>

View File

@@ -1 +1,4 @@
export { default as SettingsGeneral } from './SettingsGeneral.svelte';
export { default as SettingsMembers } from './SettingsMembers.svelte';
export { default as SettingsRoles } from './SettingsRoles.svelte';
export { default as SettingsIntegrations } from './SettingsIntegrations.svelte';

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { on } from "svelte/events";
interface MenuItem {
label: string;
icon?: string;
onclick: () => void;
danger?: boolean;
divider?: boolean;
}
interface Props {
items: MenuItem[];
align?: "left" | "right";
}
let { items, align = "right" }: Props = $props();
let isOpen = $state(false);
let containerEl = $state<HTMLElement | null>(null);
// Attach click-outside and Escape listeners only while menu is open
$effect(() => {
if (!isOpen) return;
let cleanupClick: (() => void) | undefined;
const timer = setTimeout(() => {
cleanupClick = on(document, "click", (e: MouseEvent) => {
if (containerEl && !containerEl.contains(e.target as Node)) {
isOpen = false;
}
});
}, 0);
const cleanupKey = on(document, "keydown", (e: Event) => {
if ((e as KeyboardEvent).key === "Escape") isOpen = false;
});
return () => {
clearTimeout(timer);
cleanupClick?.();
cleanupKey();
};
});
function handleItemClick(item: MenuItem) {
item.onclick();
isOpen = false;
}
</script>
<div class="relative context-menu-container" bind:this={containerEl}>
<button
type="button"
class="w-8 h-8 flex items-center justify-center rounded-full hover:bg-light/10 transition-colors"
onclick={() => (isOpen = !isOpen)}
aria-expanded={isOpen}
aria-haspopup="true"
aria-label="More options"
>
<span
class="material-symbols-rounded text-light/60 hover:text-light"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
more_horiz
</span>
</button>
{#if isOpen}
<div
class="
absolute z-50 mt-1 py-1 bg-dark border border-light/10 rounded-xl shadow-xl min-w-[180px]
animate-in fade-in slide-in-from-top-2 duration-150
{align === 'right' ? 'right-0' : 'left-0'}
"
>
{#each items as item}
{#if item.divider}
<div class="border-t border-light/10 my-1"></div>
{/if}
<button
type="button"
class="
w-full flex items-center gap-3 px-3 py-2 text-sm text-left transition-colors
{item.danger ? 'text-error hover:bg-error/10' : 'text-light hover:bg-light/5'}
"
onclick={() => handleItemClick(item)}
>
{#if item.icon}
<span
class="material-symbols-rounded shrink-0 {item.danger
? 'text-error/60'
: 'text-light/50'}"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
{item.icon}
</span>
{/if}
<span class="flex-1">{item.label}</span>
</button>
{/each}
</div>
{/if}
</div>

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
onclick?: () => void;
variant?: 'ghost' | 'subtle' | 'solid';
size?: 'sm' | 'md' | 'lg';
variant?: "ghost" | "subtle" | "solid";
size?: "sm" | "md" | "lg";
disabled?: boolean;
title?: string;
class?: string;
@@ -14,29 +14,29 @@
let {
children,
onclick,
variant = 'ghost',
size = 'md',
variant = "ghost",
size = "md",
disabled = false,
title,
class: className = '',
class: className = "",
}: Props = $props();
const variantClasses = {
ghost: 'hover:bg-light/10 text-light/60 hover:text-light',
subtle: 'bg-light/5 hover:bg-light/10 text-light/60 hover:text-light',
solid: 'bg-primary/20 hover:bg-primary/30 text-primary',
ghost: "hover:bg-light/10 text-light/60 hover:text-light",
subtle: "bg-light/5 hover:bg-light/10 text-light/60 hover:text-light",
solid: "bg-primary/20 hover:bg-primary/30 text-primary",
};
const sizeClasses = {
sm: 'w-7 h-7',
md: 'w-9 h-9',
lg: 'w-11 h-11',
sm: "w-7 h-7",
md: "w-9 h-9",
lg: "w-11 h-11",
};
const iconSizeClasses = {
sm: '[&>svg]:w-4 [&>svg]:h-4',
md: '[&>svg]:w-5 [&>svg]:h-5',
lg: '[&>svg]:w-6 [&>svg]:h-6',
sm: "[&>svg]:w-4 [&>svg]:h-4",
md: "[&>svg]:w-5 [&>svg]:h-5",
lg: "[&>svg]:w-6 [&>svg]:h-6",
};
</script>
@@ -47,7 +47,7 @@
{title}
aria-label={title}
class="
inline-flex items-center justify-center rounded-lg transition-colors
inline-flex items-center justify-center rounded-full transition-colors cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed
{variantClasses[variant]}
{sizeClasses[size]}

View File

@@ -54,7 +54,7 @@
<!-- Add button -->
{#if onAddCard}
<Button variant="secondary" fullWidth onclick={onAddCard}>
<Button variant="secondary" fullWidth icon="add" onclick={onAddCard}>
Add card
</Button>
{/if}

View File

@@ -1,39 +1,54 @@
<script lang="ts">
interface Props {
size?: "sm" | "md";
size?: "sm" | "md" | "lg";
showText?: boolean;
}
let { size = "md" }: Props = $props();
let { size = "md", showText = false }: Props = $props();
const sizeClasses = {
sm: "w-10 h-10",
md: "w-12 h-12",
const iconSizes = {
sm: "w-8 h-8",
md: "w-10 h-10",
lg: "w-12 h-12",
};
const textSizes = {
sm: "text-[14px]",
md: "text-[18px]",
lg: "text-[22px]",
};
</script>
<div class="flex items-center justify-center {sizeClasses[size]}">
<svg
viewBox="0 0 38 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-full h-auto"
>
<!-- Root logo SVG paths matching Figma -->
<path
d="M0 0.5C0 0.224 0.224 0 0.5 0H37.5C37.776 0 38 0.224 38 0.5V12.203C38 12.479 37.776 12.703 37.5 12.703H0.5C0.224 12.703 0 12.479 0 12.203V0.5Z"
fill="#00A3E0"
fill-opacity="0.2"
/>
<!-- Left eye -->
<circle cx="11.5" cy="7.5" r="5" fill="#00A3E0" />
<!-- Right eye -->
<circle cx="23.5" cy="7.5" r="5" fill="#00A3E0" />
<!-- Mouth/smile -->
<path
d="M12.25 15.04C12.25 15.04 15 20.25 18.75 20.25C22.5 20.25 25.25 15.04 25.25 15.04"
stroke="#00A3E0"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<div class="flex items-center gap-2">
<div class="shrink-0 {iconSizes[size]} transition-all duration-300">
<svg
viewBox="0 0 38 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-full h-auto"
>
<path
d="M0 0.5C0 0.224 0.224 0 0.5 0H37.5C37.776 0 38 0.224 38 0.5V12.203C38 12.479 37.776 12.703 37.5 12.703H0.5C0.224 12.703 0 12.479 0 12.203V0.5Z"
fill="#00A3E0"
fill-opacity="0.2"
/>
<circle cx="11.5" cy="7.5" r="5" fill="#00A3E0" />
<circle cx="23.5" cy="7.5" r="5" fill="#00A3E0" />
<path
d="M12.25 15.04C12.25 15.04 15 20.25 18.75 20.25C22.5 20.25 25.25 15.04 25.25 15.04"
stroke="#00A3E0"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>
{#if showText}
<span
class="font-heading {textSizes[
size
]} text-primary leading-none whitespace-nowrap transition-all duration-300"
>
Root
</span>
{/if}
</div>

View File

@@ -64,7 +64,7 @@
{title}
</h2>
<button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-full transition-colors"
onclick={onClose}
aria-label="Close"
>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import Skeleton from "./Skeleton.svelte";
interface Props {
variant?: "default" | "kanban" | "files" | "calendar" | "settings";
}
let { variant = "default" }: Props = $props();
</script>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4 animate-in">
<!-- Header skeleton -->
<header class="flex items-center gap-2 p-1">
<Skeleton variant="text" width="200px" height="2rem" />
<div class="flex-1"></div>
<Skeleton variant="rectangular" width="80px" height="40px" class="rounded-[32px]" />
<Skeleton variant="circular" width="32px" height="32px" />
</header>
{#if variant === "kanban"}
<!-- Kanban skeleton: columns with cards -->
<div class="flex gap-2 flex-1 overflow-hidden">
{#each Array(3) as _}
<div class="flex-shrink-0 w-[256px] bg-background rounded-[32px] px-4 py-5 flex flex-col gap-4">
<div class="flex items-center gap-2">
<Skeleton variant="text" width="120px" height="1.25rem" />
<Skeleton variant="rectangular" width="24px" height="20px" class="rounded-[8px]" />
</div>
{#each Array(3) as __}
<Skeleton variant="card" height="80px" class="rounded-[16px]" />
{/each}
</div>
{/each}
</div>
{:else if variant === "files"}
<!-- Files skeleton: toolbar + grid -->
<div class="flex items-center gap-2">
<Skeleton variant="text" width="300px" height="2.5rem" class="rounded-[32px]" />
<div class="flex-1"></div>
<Skeleton variant="circular" width="36px" height="36px" />
<Skeleton variant="circular" width="36px" height="36px" />
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each Array(12) as _}
<Skeleton variant="card" height="120px" class="rounded-[16px]" />
{/each}
</div>
{:else if variant === "calendar"}
<!-- Calendar skeleton: nav + grid -->
<div class="flex items-center gap-2 px-2">
<Skeleton variant="circular" width="32px" height="32px" />
<Skeleton variant="text" width="200px" height="1.5rem" />
<Skeleton variant="circular" width="32px" height="32px" />
<div class="flex-1"></div>
<Skeleton variant="rectangular" width="200px" height="32px" class="rounded-[32px]" />
</div>
<div class="flex-1 bg-background rounded-xl p-2">
<div class="grid grid-cols-7 gap-2">
{#each Array(7) as _}
<Skeleton variant="text" width="100%" height="2rem" />
{/each}
</div>
<div class="grid grid-cols-7 gap-2 mt-2">
{#each Array(35) as _}
<Skeleton variant="rectangular" width="100%" height="80px" class="rounded-none" />
{/each}
</div>
</div>
{:else if variant === "settings"}
<!-- Settings skeleton: tabs + content -->
<div class="flex gap-2">
{#each Array(4) as _}
<Skeleton variant="rectangular" width="80px" height="36px" class="rounded-[32px]" />
{/each}
</div>
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-4">
<Skeleton variant="text" width="160px" height="1.5rem" />
<Skeleton variant="text" lines={3} />
<Skeleton variant="rectangular" width="100%" height="48px" class="rounded-[32px]" />
<Skeleton variant="rectangular" width="100%" height="48px" class="rounded-[32px]" />
</div>
{:else}
<!-- Default: overview-like skeleton -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
{#each Array(4) as _}
<Skeleton variant="card" height="100px" class="rounded-2xl" />
{/each}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1">
<div class="lg:col-span-2 bg-night rounded-2xl p-5 flex flex-col gap-3">
<Skeleton variant="text" width="160px" height="1.5rem" />
{#each Array(5) as _}
<div class="flex items-center gap-3 px-3 py-2">
<Skeleton variant="circular" width="24px" height="24px" />
<Skeleton variant="text" width="80%" height="1rem" />
</div>
{/each}
</div>
<div class="flex flex-col gap-6">
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<Skeleton variant="text" width="120px" height="1.5rem" />
{#each Array(3) as _}
<Skeleton variant="rectangular" width="100%" height="40px" class="rounded-xl" />
{/each}
</div>
</div>
</div>
{/if}
</div>
<style>
.animate-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@@ -24,3 +24,5 @@ export { default as Logo } from './Logo.svelte';
export { default as ContentHeader } from './ContentHeader.svelte';
export { default as Icon } from './Icon.svelte';
export { default as AssigneePicker } from './AssigneePicker.svelte';
export { default as ContextMenu } from './ContextMenu.svelte';
export { default as PageSkeleton } from './PageSkeleton.svelte';