Quick fixes + logo better
This commit is contained in:
446
src/lib/components/settings/SettingsActivityLog.svelte
Normal file
446
src/lib/components/settings/SettingsActivityLog.svelte
Normal file
@@ -0,0 +1,446 @@
|
||||
<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>
|
||||
@@ -2,3 +2,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';
|
||||
export { default as SettingsActivityLog } from './SettingsActivityLog.svelte';
|
||||
|
||||
Reference in New Issue
Block a user