Quick fixes + logo better

This commit is contained in:
AlacrisDevs
2026-02-09 18:05:09 +02:00
parent 046d4bd098
commit c2d3caaa5a
17 changed files with 1400 additions and 288 deletions

View 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>

View File

@@ -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';