Mega push vol 5, working on messaging now
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
369
src/lib/components/settings/SettingsIntegrations.svelte
Normal file
369
src/lib/components/settings/SettingsIntegrations.svelte
Normal 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>
|
||||
398
src/lib/components/settings/SettingsMembers.svelte
Normal file
398
src/lib/components/settings/SettingsMembers.svelte
Normal 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>
|
||||
350
src/lib/components/settings/SettingsRoles.svelte
Normal file
350
src/lib/components/settings/SettingsRoles.svelte
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
104
src/lib/components/ui/ContextMenu.svelte
Normal file
104
src/lib/components/ui/ContextMenu.svelte
Normal 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>
|
||||
@@ -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]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
119
src/lib/components/ui/PageSkeleton.svelte
Normal file
119
src/lib/components/ui/PageSkeleton.svelte
Normal 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>
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user