447 lines
12 KiB
Svelte
447 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from "svelte";
|
|
import {
|
|
Avatar,
|
|
Button,
|
|
Spinner,
|
|
EmptyState,
|
|
Input,
|
|
} from "$lib/components/ui";
|
|
import * as m from "$lib/paraglide/messages";
|
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
import type { Database } from "$lib/supabase/types";
|
|
|
|
interface ActivityEntry {
|
|
id: string;
|
|
action: string;
|
|
entity_type: string;
|
|
entity_id: string | null;
|
|
entity_name: string | null;
|
|
created_at: string | null;
|
|
metadata: Record<string, unknown> | null;
|
|
profiles: {
|
|
full_name: string | null;
|
|
email: string | null;
|
|
avatar_url: string | null;
|
|
} | null;
|
|
}
|
|
|
|
interface Props {
|
|
supabase: SupabaseClient<Database>;
|
|
orgId: string;
|
|
}
|
|
|
|
let { supabase, orgId }: Props = $props();
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
let entries = $state<ActivityEntry[]>([]);
|
|
let isLoading = $state(false);
|
|
let isLoadingMore = $state(false);
|
|
let hasMore = $state(true);
|
|
let totalCount = $state<number | null>(null);
|
|
let activeFilter = $state<string>("all");
|
|
let searchQuery = $state("");
|
|
let initialLoaded = $state(false);
|
|
|
|
const filters: { id: string; label: () => string; icon: string }[] = [
|
|
{ id: "all", label: m.settings_activity_filter_all, icon: "list" },
|
|
{
|
|
id: "create",
|
|
label: m.settings_activity_filter_create,
|
|
icon: "add_circle",
|
|
},
|
|
{
|
|
id: "update",
|
|
label: m.settings_activity_filter_update,
|
|
icon: "edit",
|
|
},
|
|
{
|
|
id: "delete",
|
|
label: m.settings_activity_filter_delete,
|
|
icon: "delete",
|
|
},
|
|
{
|
|
id: "move",
|
|
label: m.settings_activity_filter_move,
|
|
icon: "drive_file_move",
|
|
},
|
|
{
|
|
id: "rename",
|
|
label: m.settings_activity_filter_rename,
|
|
icon: "edit_note",
|
|
},
|
|
];
|
|
|
|
const filteredEntries = $derived(
|
|
searchQuery.trim()
|
|
? entries.filter((e) => {
|
|
const q = searchQuery.toLowerCase();
|
|
const name = (e.entity_name ?? "").toLowerCase();
|
|
const user = (
|
|
e.profiles?.full_name ??
|
|
e.profiles?.email ??
|
|
""
|
|
).toLowerCase();
|
|
const entityType = e.entity_type.toLowerCase();
|
|
return (
|
|
name.includes(q) ||
|
|
user.includes(q) ||
|
|
entityType.includes(q)
|
|
);
|
|
})
|
|
: entries,
|
|
);
|
|
|
|
async function loadEntries(reset = false) {
|
|
if (reset) {
|
|
entries = [];
|
|
hasMore = true;
|
|
isLoading = true;
|
|
} else {
|
|
isLoadingMore = true;
|
|
}
|
|
|
|
let query = supabase
|
|
.from("activity_log")
|
|
.select(
|
|
`id, action, entity_type, entity_id, entity_name, created_at, metadata,
|
|
profiles:user_id ( full_name, email, avatar_url )`,
|
|
{ count: "exact" },
|
|
)
|
|
.eq("org_id", orgId)
|
|
.order("created_at", { ascending: false })
|
|
.range(entries.length, entries.length + PAGE_SIZE - 1);
|
|
|
|
if (activeFilter !== "all") {
|
|
query = query.eq("action", activeFilter);
|
|
}
|
|
|
|
const { data, count, error } = await query;
|
|
|
|
if (!error && data) {
|
|
if (reset) {
|
|
entries = data as unknown as ActivityEntry[];
|
|
} else {
|
|
entries = [...entries, ...(data as unknown as ActivityEntry[])];
|
|
}
|
|
totalCount = count;
|
|
hasMore = data.length === PAGE_SIZE;
|
|
}
|
|
|
|
isLoading = false;
|
|
isLoadingMore = false;
|
|
initialLoaded = true;
|
|
}
|
|
|
|
$effect(() => {
|
|
// Re-load when filter changes — only track activeFilter, not internal state
|
|
const _filter = activeFilter;
|
|
untrack(() => loadEntries(true));
|
|
});
|
|
|
|
function getEntityTypeLabel(entityType: string): string {
|
|
const map: Record<string, () => string> = {
|
|
document: m.entity_document,
|
|
folder: m.entity_folder,
|
|
kanban_board: m.entity_kanban_board,
|
|
kanban_card: m.entity_kanban_card,
|
|
kanban_column: m.entity_kanban_column,
|
|
member: m.entity_member,
|
|
role: m.entity_role,
|
|
invite: m.entity_invite,
|
|
event: m.entity_event,
|
|
};
|
|
return (map[entityType] ?? (() => entityType))();
|
|
}
|
|
|
|
function getActivityIcon(action: string): string {
|
|
const map: Record<string, string> = {
|
|
create: "add_circle",
|
|
update: "edit",
|
|
delete: "delete",
|
|
move: "drive_file_move",
|
|
rename: "edit_note",
|
|
};
|
|
return map[action] ?? "info";
|
|
}
|
|
|
|
function getActivityColor(action: string): string {
|
|
const map: Record<string, string> = {
|
|
create: "text-emerald-400",
|
|
update: "text-blue-400",
|
|
delete: "text-red-400",
|
|
move: "text-amber-400",
|
|
rename: "text-purple-400",
|
|
};
|
|
return map[action] ?? "text-light/50";
|
|
}
|
|
|
|
function getActivityBg(action: string): string {
|
|
const map: Record<string, string> = {
|
|
create: "bg-emerald-400/10",
|
|
update: "bg-blue-400/10",
|
|
delete: "bg-red-400/10",
|
|
move: "bg-amber-400/10",
|
|
rename: "bg-purple-400/10",
|
|
};
|
|
return map[action] ?? "bg-light/5";
|
|
}
|
|
|
|
function getDescription(entry: ActivityEntry): string {
|
|
const userName =
|
|
entry.profiles?.full_name || entry.profiles?.email || "Someone";
|
|
const entityType = getEntityTypeLabel(entry.entity_type);
|
|
const name = entry.entity_name ?? "-";
|
|
|
|
const map: Record<string, () => string> = {
|
|
create: () =>
|
|
m.activity_created({ user: userName, entityType, name }),
|
|
update: () =>
|
|
m.activity_updated({ user: userName, entityType, name }),
|
|
delete: () =>
|
|
m.activity_deleted({ user: userName, entityType, name }),
|
|
move: () => m.activity_moved({ user: userName, entityType, name }),
|
|
rename: () =>
|
|
m.activity_renamed({ user: userName, entityType, name }),
|
|
};
|
|
return (map[entry.action] ?? map["update"]!)();
|
|
}
|
|
|
|
function formatFullDate(dateStr: string | null): string {
|
|
if (!dateStr) return "";
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
function formatRelativeDate(dateStr: string | null): string {
|
|
if (!dateStr) return "";
|
|
const now = Date.now();
|
|
const then = new Date(dateStr).getTime();
|
|
const diffMs = now - then;
|
|
const diffMin = Math.floor(diffMs / 60000);
|
|
if (diffMin < 1) return m.activity_just_now();
|
|
if (diffMin < 60)
|
|
return m.activity_minutes_ago({ count: String(diffMin) });
|
|
const diffHr = Math.floor(diffMin / 60);
|
|
if (diffHr < 24) return m.activity_hours_ago({ count: String(diffHr) });
|
|
const diffDay = Math.floor(diffHr / 24);
|
|
return m.activity_days_ago({ count: String(diffDay) });
|
|
}
|
|
|
|
function getDateGroup(dateStr: string | null): string {
|
|
if (!dateStr) return "";
|
|
const d = new Date(dateStr);
|
|
const now = new Date();
|
|
const today = new Date(
|
|
now.getFullYear(),
|
|
now.getMonth(),
|
|
now.getDate(),
|
|
);
|
|
const entryDate = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
const diffDays = Math.floor(
|
|
(today.getTime() - entryDate.getTime()) / 86400000,
|
|
);
|
|
|
|
if (diffDays === 0) return "Today";
|
|
if (diffDays === 1) return "Yesterday";
|
|
if (diffDays < 7) return `${diffDays} days ago`;
|
|
return d.toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
// Group entries by date
|
|
const groupedEntries = $derived(() => {
|
|
const groups: { label: string; entries: ActivityEntry[] }[] = [];
|
|
let currentLabel = "";
|
|
for (const entry of filteredEntries) {
|
|
const label = getDateGroup(entry.created_at);
|
|
if (label !== currentLabel) {
|
|
currentLabel = label;
|
|
groups.push({ label, entries: [] });
|
|
}
|
|
groups[groups.length - 1].entries.push(entry);
|
|
}
|
|
return groups;
|
|
});
|
|
</script>
|
|
|
|
<div class="space-y-4">
|
|
<!-- Header -->
|
|
<div
|
|
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3"
|
|
>
|
|
<div>
|
|
<h2 class="text-body font-heading text-white">
|
|
{m.settings_activity_title()}
|
|
</h2>
|
|
<p class="text-body-sm text-light/50 mt-0.5">
|
|
{m.settings_activity_desc()}
|
|
</p>
|
|
</div>
|
|
{#if totalCount !== null}
|
|
<span
|
|
class="text-[11px] text-light/30 bg-dark/50 px-3 py-1.5 rounded-lg shrink-0"
|
|
>
|
|
{m.settings_activity_count({ count: String(totalCount) })}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Filters + Search -->
|
|
<div class="flex flex-col sm:flex-row gap-3">
|
|
<div class="flex flex-wrap gap-1.5">
|
|
{#each filters as filter}
|
|
<button
|
|
type="button"
|
|
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[12px] font-body transition-colors {activeFilter ===
|
|
filter.id
|
|
? 'bg-primary text-background'
|
|
: 'text-light/50 hover:text-white hover:bg-dark/50 bg-dark/20'}"
|
|
onclick={() => (activeFilter = filter.id)}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
|
>{filter.icon}</span
|
|
>
|
|
{filter.label()}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<div class="sm:ml-auto w-full sm:w-64">
|
|
<Input
|
|
placeholder={m.settings_activity_search_placeholder()}
|
|
bind:value={searchQuery}
|
|
icon="search"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
{#if isLoading}
|
|
<div class="flex items-center justify-center py-16">
|
|
<Spinner size="lg" />
|
|
</div>
|
|
{:else if filteredEntries.length === 0 && initialLoaded}
|
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-8">
|
|
<EmptyState
|
|
title={m.settings_activity_empty()}
|
|
description={searchQuery
|
|
? "Try a different search term."
|
|
: m.settings_activity_desc()}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-col gap-1">
|
|
{#each groupedEntries() as group}
|
|
<!-- Date group header -->
|
|
<div
|
|
class="sticky top-0 z-10 bg-background/80 backdrop-blur-sm px-1 py-2 mt-2 first:mt-0"
|
|
>
|
|
<span
|
|
class="text-[11px] font-heading text-light/30 uppercase tracking-wider"
|
|
>
|
|
{group.label}
|
|
</span>
|
|
</div>
|
|
|
|
{#each group.entries as entry (entry.id)}
|
|
<div
|
|
class="flex items-start gap-3 px-3 py-3 rounded-xl hover:bg-dark/30 transition-colors group"
|
|
>
|
|
<!-- Icon -->
|
|
<div
|
|
class="w-8 h-8 rounded-lg {getActivityBg(
|
|
entry.action,
|
|
)} flex items-center justify-center shrink-0 mt-0.5"
|
|
>
|
|
<span
|
|
class="material-symbols-rounded {getActivityColor(
|
|
entry.action,
|
|
)}"
|
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
|
>{getActivityIcon(entry.action)}</span
|
|
>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="flex-1 min-w-0">
|
|
<p
|
|
class="text-body-sm text-light/80 leading-relaxed"
|
|
>
|
|
{getDescription(entry)}
|
|
</p>
|
|
<div class="flex items-center gap-3 mt-1">
|
|
{#if entry.profiles}
|
|
<div class="flex items-center gap-1.5">
|
|
<Avatar
|
|
name={entry.profiles.full_name ??
|
|
entry.profiles.email ??
|
|
"?"}
|
|
src={entry.profiles.avatar_url}
|
|
size="xs"
|
|
/>
|
|
<span class="text-[11px] text-light/40">
|
|
{entry.profiles.full_name ??
|
|
entry.profiles.email}
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
<span class="text-[11px] text-light/20">·</span>
|
|
<span
|
|
class="text-[11px] text-light/30"
|
|
title={formatFullDate(entry.created_at)}
|
|
>
|
|
{formatRelativeDate(entry.created_at)}
|
|
</span>
|
|
<span
|
|
class="text-[11px] text-light/20 hidden group-hover:inline"
|
|
>
|
|
{formatFullDate(entry.created_at)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Entity type badge -->
|
|
<span
|
|
class="text-[10px] text-light/30 bg-dark/50 px-2 py-1 rounded-md shrink-0 mt-1"
|
|
>
|
|
{getEntityTypeLabel(entry.entity_type)}
|
|
</span>
|
|
</div>
|
|
{/each}
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Load more / End -->
|
|
<div class="flex justify-center py-4">
|
|
{#if isLoadingMore}
|
|
<Spinner />
|
|
{:else if hasMore}
|
|
<Button
|
|
variant="tertiary"
|
|
size="sm"
|
|
icon="expand_more"
|
|
onclick={() => loadEntries(false)}
|
|
>
|
|
{m.settings_activity_load_more()}
|
|
</Button>
|
|
{:else if entries.length > 0}
|
|
<p class="text-[11px] text-light/20">
|
|
{m.settings_activity_end()}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|