visual continunity

This commit is contained in:
Bradley Nelson
2026-06-17 22:07:18 -06:00
parent daa3010458
commit 89e14f8f9e
89 changed files with 19249 additions and 1367 deletions
+5 -3
View File
@@ -4,9 +4,10 @@
import { onMount } from 'svelte';
import '$lib/styles/app.css';
import AppShell from '$lib/components/AppShell.svelte';
import DialogHost from '$lib/components/DialogHost.svelte';
import Toaster from '$lib/components/Toaster.svelte';
import { session } from '$lib/stores/session.svelte';
import { legacyHashToPath } from '$lib/utils/legacyHash';
import { hashUrlToPath } from '$lib/utils/hashRedirect';
let { children } = $props();
@@ -20,9 +21,9 @@
let ready = $state(false);
onMount(async () => {
// Redirect legacy `#/...` bookmarks to the new path before anything else.
// Redirect old `#/...` bookmarks to the new path before anything else.
if (typeof location !== 'undefined' && location.hash.startsWith('#/')) {
const mapped = legacyHashToPath(location.hash);
const mapped = hashUrlToPath(location.hash);
if (mapped) await goto(mapped, { replaceState: true });
}
await session.load();
@@ -51,6 +52,7 @@
{/if}
<Toaster />
<DialogHost />
<style>
.app-loading {
File diff suppressed because it is too large Load Diff
+82 -4
View File
@@ -1,17 +1,45 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { decideDevice, lookupDeviceCode, type DeviceInfo } from '$lib/api/endpoints/device';
import {
decideDevice,
DeviceLookupFailure,
lookupDeviceCode,
type DeviceInfo
} from '$lib/api/endpoints/device';
import { t } from '$lib/i18n/index.svelte';
type Step = 'code' | 'loading' | 'review' | 'approved' | 'denied' | 'error';
// A complete user-code is 8 chars + a hyphen (e.g. ABCD-1234) → length 9.
const FULL_CODE_LENGTH = 9;
let code = $state(page.url.searchParams.get('code') ?? '');
let step = $state<Step>('code');
let info = $state<DeviceInfo | null>(null);
let errorText = $state('');
let busy = $state(false);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let codeInput = $state<HTMLInputElement | null>(null);
function failureMessage(err: unknown): string {
if (err instanceof DeviceLookupFailure) {
switch (err.kind) {
case 'unauthorized':
return t(
'device.unauthorized',
'You must be logged in to authorize a device. Please log in first.'
);
case 'not-found':
return t('device.not_found', 'Code not found or expired. Please check and try again.');
default:
return t('device.lookup_failed', 'Failed to verify code. Please try again.');
}
}
return err instanceof Error ? err.message : String(err);
}
async function lookup(e?: SubmitEvent) {
e?.preventDefault();
if (!code) return;
@@ -21,11 +49,34 @@
info = await lookupDeviceCode(code);
step = 'review';
} catch (err) {
errorText = err instanceof Error ? err.message : String(err);
errorText = failureMessage(err);
step = 'error';
}
}
/**
* Normalise the code field as the user types: uppercase, strip anything
* that isn't [A-Z0-9-], auto-insert the hyphen after the first 4 chars,
* then debounce a lookup once a full code is present.
*/
function onCodeInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
let val = target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '');
if (val.length === 4 && !val.includes('-')) val = `${val}-`;
code = val;
// Reflect the normalised value back into the input.
target.value = val;
if (step === 'error') {
step = 'code';
errorText = '';
}
clearTimeout(debounceTimer);
if (val.length >= FULL_CODE_LENGTH) {
debounceTimer = setTimeout(() => void lookup(), 300);
}
}
async function decide(action: 'approve' | 'deny') {
busy = true;
try {
@@ -39,8 +90,16 @@
}
}
function backToCode() {
step = 'code';
errorText = '';
// Re-focus so the user can correct the code immediately.
queueMicrotask(() => codeInput?.focus());
}
onMount(() => {
if (code) void lookup();
else codeInput?.focus();
});
</script>
@@ -48,13 +107,32 @@
<main class="device">
<div class="device__card">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="120 120 280 280" aria-hidden="true">
<path
d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z"
/>
</svg>
</div>
<div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div>
</div>
<h1>{t('device.title', 'Device verification')}</h1>
{#if step === 'code'}
<form onsubmit={lookup}>
<label class="device__field">
<span>{t('device.enter_code', 'Enter the code shown on your device')}</span>
<input bind:value={code} autocomplete="off" inputmode="text" />
<input
bind:this={codeInput}
value={code}
oninput={onCodeInput}
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
inputmode="text"
maxlength={FULL_CODE_LENGTH}
/>
</label>
<button type="submit" disabled={!code}>{t('device.continue', 'Continue')}</button>
</form>
@@ -83,7 +161,7 @@
<p>{t('device.denied', 'Device access denied.')}</p>
{:else if step === 'error'}
<p class="device__error" role="alert">{errorText}</p>
<button onclick={() => (step = 'code')}>{t('common.retry', 'Try again')}</button>
<button onclick={backToCode}>{t('common.retry', 'Try again')}</button>
{/if}
</div>
</main>
+293 -47
View File
@@ -1,39 +1,272 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import {
dateBucket,
fetchFavoritesPage,
removeFavorite,
resolveOwnerName,
sizeBucket,
typeLabel,
type FavoritesResourceItem
} from '$lib/api/endpoints/favorites';
import { fileDownloadUrl } from '$lib/api/endpoints/files';
import { renameFile, deleteFile } from '$lib/api/endpoints/files';
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
import type { FileItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import FileViewer from '$lib/components/FileViewer.svelte';
import MoveDialog from '$lib/components/MoveDialog.svelte';
import ShareDialog from '$lib/components/ShareDialog.svelte';
import ResourceList, {
type ContextAction,
type GroupByDef,
type ResourceEntry
} from '$lib/components/ResourceList.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<FavoritesResourceItem[]>([]);
let raw = $state<FavoritesResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
let groupBy = $state('');
let reversed = $state(false);
let ownerNames = $state<Record<string, string>>({});
async function load(reset = false) {
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
const entries = $derived(
raw.map((it): ResourceEntry => {
const isFile = it.resource_type === 'file';
const ownerId = it.resource.owner_id ?? null;
return {
id: it.resource.id,
name: it.resource.name,
kind: it.resource_type,
iconClass: it.resource.icon_class,
path: it.resource.path,
size: isFile ? (it.resource as FileItem).size : null,
date: it.favorited_at,
ownerId,
ownerName: ownerId ? (ownerNames[ownerId] ?? null) : null,
isFavorite: true,
category: isFile ? it.resource.category : 'Folder',
modifiedAt: it.resource.modified_at
};
})
);
const groupBys: GroupByDef[] = [
{ key: '', label: t('files.name', 'Name'), orderBy: 'name' },
{
key: 'owner',
label: t('groupby.owner', 'Owner'),
orderBy: 'owner',
bucketOf: (e) => e.ownerId ?? null,
labelOf: (id) => ownerNames[id] ?? id
},
{
key: 'type',
label: t('groupby.type', 'Type'),
orderBy: 'type',
bucketOf: (e) => e.category ?? 'other',
labelOf: (k) => typeLabel(k)
},
{
key: 'size',
label: t('groupby.size', 'Size'),
orderBy: 'size',
bucketOf: (e) => sizeBucket(e.kind === 'folder' ? null : e.size)
},
{
key: 'favoriteDate',
label: t('groupby.favoriteDate', 'Favorite date'),
orderBy: 'favorited_at',
bucketOf: (e) => dateBucket(e.date)
},
{
key: 'modifiedAt',
label: t('groupby.modifiedAt', 'Modified date'),
orderBy: 'modified_at',
bucketOf: (e) => dateBucket(e.modifiedAt)
}
];
async function resolveOwners(items: FavoritesResourceItem[]) {
const ids = [
...new Set(items.map((i) => i.resource.owner_id).filter((id): id is string => !!id))
];
await Promise.all(
ids.map(async (id) => {
if (ownerNames[id]) return;
const name = await resolveOwnerName(id);
ownerNames = { ...ownerNames, [id]: name };
})
);
}
async function load(reset = false, orderBy = 'name', rev = reversed) {
loading = true;
error = null;
try {
const page = await fetchFavoritesPage({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
const page = await fetchFavoritesPage({
cursor: reset ? undefined : cursor,
orderBy,
reverse: rev,
resourceTypes: ['file', 'folder']
});
raw = reset ? page.items : [...raw, ...page.items];
cursor = page.next_cursor;
void resolveOwners(page.items);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
console.error('favorites: load error', e);
error = t('errors_loadFailed', 'Failed to load items');
} finally {
loading = false;
}
}
async function unfavorite(item: FavoritesResourceItem) {
function orderByForGroup(): string {
return groupBys.find((g) => g.key === groupBy)?.orderBy ?? 'name';
}
let viewerOpen = $state(false);
let viewerFile = $state<FileItem | null>(null);
function open(entry: ResourceEntry) {
if (entry.kind === 'folder') {
goto(`/files/${entry.id}`);
return;
}
const item = byId.get(entry.id);
if (item) {
viewerFile = item.resource as FileItem;
viewerOpen = true;
}
}
async function unfavorite(entry: ResourceEntry) {
try {
await removeFavorite(item.resource_type, item.resource.id);
items = items.filter((i) => i.resource.id !== item.resource.id);
await removeFavorite(entry.kind, entry.id);
raw = raw.filter((i) => i.resource.id !== entry.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
// ── Context-menu actions ──────────────────────────────────────────────────
let moveOpen = $state(false);
let moveTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
let moveItems = $state<{ id: string; name: string; kind: 'file' | 'folder' }[] | null>(null);
let shareOpen = $state(false);
let shareTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
async function rename(entry: ResourceEntry) {
const name = await promptDialog({
title: t('common.rename', 'Rename'),
defaultValue: entry.name,
confirmText: t('common.rename', 'Rename')
});
if (!name || name === entry.name) return;
try {
if (entry.kind === 'file') await renameFile(entry.id, name);
else await renameFolder(entry.id, name);
await load(true, orderByForGroup());
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function remove(entry: ResourceEntry) {
const ok = await confirmDialog({
title: t('common.delete', 'Delete'),
message: t('files.confirm_delete', { name: entry.name }, 'Delete "{{name}}"?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
if (entry.kind === 'file') await deleteFile(entry.id);
else await deleteFolder(entry.id);
raw = raw.filter((i) => i.resource.id !== entry.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
function downloadEntry(entry: ResourceEntry) {
if (entry.kind !== 'file') return;
const a = document.createElement('a');
a.href = fileDownloadUrl(entry.id);
a.download = entry.name;
document.body.appendChild(a);
a.click();
a.remove();
}
const contextActions: ContextAction[] = [
{
key: 'download',
label: t('common.download', 'Download'),
icon: 'download',
run: downloadEntry
},
{
key: 'share',
label: t('files.share', 'Share'),
icon: 'share-alt',
run: (e) => {
shareTarget = { id: e.id, name: e.name, kind: e.kind };
shareOpen = true;
}
},
{
key: 'move',
label: t('files.move', 'Move'),
icon: 'arrows-alt',
run: (e) => {
moveItems = null;
moveTarget = { id: e.id, name: e.name, kind: e.kind };
moveOpen = true;
}
},
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
];
// ── Selection + batch ─────────────────────────────────────────────────────
let selectedIds = $state<Set<string>>(new Set());
const selectedEntries = $derived(entries.filter((e) => selectedIds.has(e.id)));
function batchTargets() {
return selectedEntries.map((e) => ({ id: e.id, name: e.name, kind: e.kind }));
}
function batchDownload() {
for (const e of selectedEntries) downloadEntry(e);
}
async function batchDelete() {
const ok = await confirmDialog({
title: t('common.delete', 'Delete'),
message: t(
'files.confirm_delete_n',
{ count: selectedEntries.length },
'Delete {{count}} item(s)?'
),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
await Promise.all(
selectedEntries.map((e) => (e.kind === 'file' ? deleteFile(e.id) : deleteFolder(e.id)))
);
const removed = new Set(selectedEntries.map((e) => e.id));
raw = raw.filter((i) => !removed.has(i.resource.id));
selectedIds = new Set();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
@@ -44,45 +277,58 @@
<svelte:head><title>{t('nav.favorites', 'Favorites')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.favorites', 'Favorites')}</h1>
<ResourceListShell
<ResourceList
title={t('nav.favorites', 'Favorites')}
items={entries}
{loading}
{error}
empty={items.length === 0}
emptyText={t('favorites.empty', 'No favorites yet.')}
emptyIcon="star"
emptyText={t('favorites.empty_state', 'No favorites yet')}
emptyHint={t('favorites.empty_hint', 'Star files and folders to find them here quickly')}
hasMore={!!cursor}
onloadmore={() => load(false)}
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={unfavorite}
showOwner
selectable
{contextActions}
{groupBys}
bind:groupBy
bind:reversed
onreload={(orderBy, rev) => {
cursor = undefined;
load(true, orderBy, rev);
}}
onselectionchange={(ids) => (selectedIds = ids)}
>
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.resource.path}
date={formatDate(item.favorited_at)}
{#snippet batchToolbar()}
<button class="btn btn-secondary" onclick={batchDownload}>
<Icon name="download" />
{t('common.download', 'Download')}
</button>
<button
class="btn btn-secondary"
onclick={() => {
moveTarget = null;
moveItems = batchTargets();
moveOpen = true;
}}><Icon name="arrows-alt" /> {t('files.move', 'Move')}</button
>
{#snippet actions()}
<button class="link-btn" onclick={() => unfavorite(item)}>
{t('favorites.remove', 'Remove')}
</button>
{/snippet}
</FileRow>
{/each}
</ResourceListShell>
<button class="btn btn-danger" onclick={batchDelete}>
<Icon name="trash" />
{t('common.delete', 'Delete')}
</button>
{/snippet}
</ResourceList>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
}
</style>
<FileViewer bind:open={viewerOpen} file={viewerFile} />
<MoveDialog
bind:open={moveOpen}
item={moveTarget}
items={moveItems}
onmoved={() => {
selectedIds = new Set();
load(true, orderByForGroup());
}}
/>
<ShareDialog bind:open={shareOpen} item={shareTarget} />
File diff suppressed because it is too large Load Diff
+345 -39
View File
@@ -1,10 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
addGroupMember,
addUserMember,
createGroup,
deleteGroup,
listGroups,
groupDisplayName,
groupIconName,
INTERNAL_GROUP_ID,
listGroupsPage,
listMembers,
removeGroupMember,
removeUserMember,
@@ -12,20 +16,43 @@
type GroupItem,
type GroupMember
} from '$lib/api/endpoints/groups';
import {
ensureResolvers,
resolveRecipient,
searchRecipients,
type Recipient
} from '$lib/api/endpoints/recipients';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { promptDialog } from '$lib/stores/dialogs.svelte';
import { ui } from '$lib/stores/ui.svelte';
const PAGE_SIZE = 50;
let groups = $state<GroupItem[]>([]);
let total = $state(0);
let loading = $state(false);
let loadingMore = $state(false);
let error = $state<string | null>(null);
let expandedId = $state<string | null>(null);
let members = $state<GroupMember[]>([]);
let resolverReady = $state(false);
const hasMore = $derived(groups.length < total);
// Add-member combobox state (scoped to the expanded group)
let addQuery = $state('');
let addResults = $state<Recipient[]>([]);
let addBusy = $state(false);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
async function load() {
loading = true;
error = null;
try {
groups = await listGroups();
const page = await listGroupsPage(PAGE_SIZE, 0);
groups = page.items;
total = page.total;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
@@ -33,11 +60,42 @@
}
}
async function loadMore() {
loadingMore = true;
try {
const page = await listGroupsPage(PAGE_SIZE, groups.length);
groups = [...groups, ...page.items];
total = page.total;
} catch (e) {
report(e);
} finally {
loadingMore = false;
}
}
function report(e: unknown) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
/** Localised "(N members)" label. Project i18n has no plural rules, so we
* branch on the three named forms ourselves. */
function memberCountLabel(count: number): string {
if (count === 0) return t('groups.member_count_zero', 'no members');
if (count === 1) return t('groups.member_count_one', '1 member');
return t('groups.member_count_other', { count }, '{{count}} members');
}
/** Display info for a member row; falls back to the raw id until caches load. */
function memberInfo(m: GroupMember): { label: string; sublabel?: string } {
// resolverReady gates re-resolution once the caches are populated.
void resolverReady;
const r = resolveRecipient(m.kind, m.id);
return { label: r.label, sublabel: r.sublabel };
}
async function expand(g: GroupItem) {
addQuery = '';
addResults = [];
if (expandedId === g.id) {
expandedId = null;
return;
@@ -52,10 +110,14 @@
}
async function onCreate() {
const name = prompt(t('groups.new_prompt', 'New group name'));
if (!name) return;
const name = await promptDialog({
title: t('groups.create_dialog_title', 'New group'),
placeholder: t('groups.name_placeholder', 'engineering'),
confirmText: t('actions.create', 'Create')
});
if (!name || !name.trim()) return;
try {
await createGroup(name);
await createGroup(name.trim());
await load();
} catch (e) {
report(e);
@@ -63,10 +125,15 @@
}
async function onRename(g: GroupItem) {
const name = prompt(t('groups.rename_prompt', 'New name'), g.name);
if (!name || name === g.name) return;
const name = await promptDialog({
title: t('groups.edit_dialog_title', 'Rename group'),
defaultValue: g.name,
placeholder: t('groups.name_placeholder', 'engineering'),
confirmText: t('actions.rename', 'Rename')
});
if (!name || !name.trim() || name.trim() === g.name) return;
try {
await renameGroup(g.id, name);
await renameGroup(g.id, name.trim());
await load();
} catch (e) {
report(e);
@@ -74,7 +141,25 @@
}
async function onDelete(g: GroupItem) {
if (!confirm(t('groups.confirm_delete', { name: g.name }, 'Delete group "{{name}}"?'))) return;
// Typed-name confirmation: the user must type the exact group name.
const typed = await promptDialog({
title: t('groups.delete_group', 'Delete group'),
message: t(
'groups.delete_confirm',
{ name: g.name },
'Delete the group "{{name}}"? Type the group name to confirm.'
),
placeholder: g.name,
confirmText: t('actions.delete', 'Delete')
});
if (typed === null) return;
if (typed !== g.name) {
ui.notify(
t('groups.delete_confirm_mismatch', 'Type the group name exactly to confirm.'),
'error'
);
return;
}
try {
await deleteGroup(g.id);
if (expandedId === g.id) expandedId = null;
@@ -84,11 +169,34 @@
}
}
async function onAddMember(g: GroupItem) {
const userId = prompt(t('groups.add_member_prompt', 'User ID to add'));
if (!userId) return;
function onAddQuery() {
if (searchTimer) clearTimeout(searchTimer);
const q = addQuery;
if (!q.trim()) {
addResults = [];
return;
}
searchTimer = setTimeout(async () => {
addBusy = true;
try {
const all = await searchRecipients(q);
// Don't offer the group as a member of itself, or current members.
const existing = new Set(members.map((m) => m.id));
addResults = all.filter((r) => r.id !== expandedId && !existing.has(r.id));
} catch (e) {
report(e);
} finally {
addBusy = false;
}
}, 200);
}
async function pickMember(g: GroupItem, r: Recipient) {
try {
await addUserMember(g.id, userId);
if (r.type === 'group') await addGroupMember(g.id, r.id);
else await addUserMember(g.id, r.id);
addQuery = '';
addResults = [];
members = await listMembers(g.id);
} catch (e) {
report(e);
@@ -97,15 +205,23 @@
async function onRemoveMember(groupId: string, m: GroupMember) {
try {
if (m.user_id) await removeUserMember(groupId, m.user_id);
else if (m.group_id) await removeGroupMember(groupId, m.group_id);
if (m.kind === 'user') await removeUserMember(groupId, m.id);
else await removeGroupMember(groupId, m.id);
members = await listMembers(groupId);
} catch (e) {
report(e);
}
}
onMount(load);
onMount(async () => {
await load();
try {
await ensureResolvers();
resolverReady = true;
} catch {
/* names fall back to ids */
}
});
</script>
<svelte:head><title>{t('nav.groups', 'Groups')} · OxiCloud</title></svelte:head>
@@ -129,40 +245,101 @@
<li class="group">
<div class="group__row">
<button class="group__name" onclick={() => expand(g)}>
{g.name}
{#if g.member_count != null}<span class="muted">({g.member_count})</span>{/if}
<span class="avatar"><Icon name={groupIconName(g)} /></span>
<span class="group__text">
<span class="group__title">
{groupDisplayName(g)}
{#if g.is_virtual}<span class="badge badge--system"
>{t('groups.virtual_badge', 'System')}</span
>{/if}
</span>
{#if g.description}<span class="muted">{g.description}</span>{/if}
{#if !g.is_virtual && g.member_count != null}
<span class="muted">{memberCountLabel(g.member_count)}</span>
{/if}
</span>
</button>
<div class="group__actions">
<button class="link-btn" onclick={() => onRename(g)}
>{t('common.rename', 'Rename')}</button
>
<button class="link-btn link-btn--danger" onclick={() => onDelete(g)}>
{t('common.delete', 'Delete')}
</button>
</div>
{#if g.can_manage !== false && !g.is_virtual}
<div class="group__actions">
<button class="link-btn" onclick={() => onRename(g)}
>{t('common.rename', 'Rename')}</button
>
<button class="link-btn link-btn--danger" onclick={() => onDelete(g)}>
{t('common.delete', 'Delete')}
</button>
</div>
{/if}
</div>
{#if expandedId === g.id}
<div class="members">
<div class="members__head">
<h2>{t('groups.members', 'Members')}</h2>
<button class="link-btn" onclick={() => onAddMember(g)}>
{t('groups.add_member', 'Add member')}
</button>
</div>
{#if g.can_manage !== false && !g.is_virtual}
<div class="add-member">
<input
class="add-member__input"
placeholder={t('groups.add_member_search', 'Search users or groups to add…')}
bind:value={addQuery}
oninput={onAddQuery}
/>
{#if addBusy}
<p class="muted">{t('common.loading', 'Loading…')}</p>
{:else if addResults.length > 0}
<ul class="add-member__results">
{#each addResults as r (r.type + r.id)}
<li>
<button class="add-member__opt" onclick={() => pickMember(g, r)}>
<span class="avatar avatar--sm">
<Icon name={r.type === 'group' ? 'user-group' : 'user'} />
</span>
<span class="vignette__text">
<span class="vignette__name">{r.label}</span>
{#if r.sublabel}<span class="muted">{r.sublabel}</span>{/if}
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{:else if g.id === INTERNAL_GROUP_ID}
<p class="muted">
{t('groups.virtual_internal_explanation', 'Every internal user on this server.')}
</p>
{/if}
{#if members.length === 0}
<p class="muted">{t('groups.no_members', 'No members.')}</p>
{#if !(g.id === INTERNAL_GROUP_ID)}
<p class="muted">{t('groups.no_members', 'No members.')}</p>
{/if}
{:else}
<ul class="members__list">
{#each members as m (m.user_id ?? m.group_id)}
<li>
<span>{m.email ?? m.name ?? m.user_id ?? m.group_id}</span>
<button
class="link-btn link-btn--danger"
onclick={() => onRemoveMember(g.id, m)}
>
{t('common.remove', 'Remove')}
</button>
{#each members as m (m.kind + m.id)}
{@const info = memberInfo(m)}
<li class="vignette">
<span class="avatar avatar--sm">
<Icon name={m.kind === 'group' ? 'user-group' : 'user'} />
</span>
<span class="vignette__text">
<span class="vignette__name">
{info.label}
{#if m.kind === 'group'}<span class="badge badge--nested"
>{t('groups.nested', 'Group')}</span
>{/if}
</span>
{#if info.sublabel}<span class="muted">{info.sublabel}</span>{/if}
</span>
{#if g.can_manage !== false && !g.is_virtual}
<button
class="link-btn link-btn--danger"
onclick={() => onRemoveMember(g.id, m)}
>
{t('common.remove', 'Remove')}
</button>
{/if}
</li>
{/each}
</ul>
@@ -172,6 +349,12 @@
</li>
{/each}
</ul>
{#if hasMore}
<button class="btn load-more" disabled={loadingMore} onclick={loadMore}>
{loadingMore ? t('common.loading', 'Loading…') : t('groups.load_more', 'Load more')}
</button>
{/if}
{/if}
</main>
@@ -216,19 +399,40 @@
align-items: center;
justify-content: space-between;
padding: 0.75rem;
gap: 0.5rem;
}
.group__name {
display: flex;
align-items: center;
gap: var(--space-2);
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 1rem;
text-align: left;
flex: 1;
min-width: 0;
}
.group__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.group__title {
display: flex;
align-items: center;
gap: var(--space-2);
}
.group__actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.members {
@@ -262,6 +466,104 @@
justify-content: space-between;
}
.vignette {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
}
.vignette__text {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
text-align: left;
}
.vignette__name {
display: flex;
align-items: center;
gap: var(--space-2);
}
.avatar {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--color-accent-bg, var(--color-bg-muted));
color: var(--color-accent-text, var(--color-text));
font-size: var(--text-xs, 0.75rem);
flex-shrink: 0;
}
.avatar--sm {
width: 1.6rem;
height: 1.6rem;
}
.badge {
display: inline-block;
padding: 0.05rem 0.4rem;
border-radius: var(--radius-sm);
font-size: var(--text-xs, 0.7rem);
font-weight: var(--weight-semibold, 600);
}
.badge--system {
background: var(--color-warning-bg);
color: var(--color-warning-text);
}
.badge--nested {
background: var(--color-info-bg);
color: var(--color-info-text);
}
.add-member {
position: relative;
margin: var(--space-2) 0;
}
.add-member__input {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.add-member__results {
list-style: none;
margin: var(--space-1) 0 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
max-height: 16rem;
overflow: auto;
}
.add-member__opt {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
text-align: left;
}
.add-member__opt:hover {
background: var(--color-bg-hover);
}
.muted {
color: var(--color-text-muted);
font-size: 0.8125rem;
@@ -292,6 +594,10 @@
border-color: transparent;
}
.load-more {
align-self: center;
}
.link-btn {
background: none;
border: none;
+585 -48
View File
@@ -1,29 +1,97 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { login } from '$lib/api/endpoints/auth';
import { t } from '$lib/i18n/index.svelte';
import { onMount } from 'svelte';
import {
exchangeOidcCode,
fetchMe,
getAuthStatus,
getOidcProviders,
login,
register,
sendMagicLink,
setupAdmin,
type OidcProviders
} from '$lib/api/endpoints/auth';
import { i18n, SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
type Mode = 'login' | 'register' | 'setup';
let mode = $state<Mode>('login');
// First-run admin setup is only offered after the status probe confirms it.
let setupAvailable = $state(false);
// Suppress the auth UI until the onMount probes (session/oidc/status) settle,
// to avoid flashing the login form before a redirect or the setup wizard.
let booting = $state(true);
// Login
let username = $state('');
let password = $state('');
let showPassword = $state(false);
let capsOn = $state(false);
let error = $state('');
let busy = $state(false);
// Register
let regUsername = $state('');
let regEmail = $state('');
let regPassword = $state('');
let regConfirm = $state('');
let regError = $state('');
let regSuccess = $state('');
let regShowPassword = $state(false);
let regShowConfirm = $state(false);
let regCapsOn = $state(false);
// Admin setup (first run)
let setupEmail = $state('');
let setupPassword = $state('');
let setupConfirm = $state('');
let setupShowPassword = $state(false);
let setupShowConfirm = $state(false);
let setupCapsOn = $state(false);
let setupError = $state('');
let setupSuccess = $state('');
const setupMatchState = $derived(
setupConfirm.length === 0 ? '' : setupPassword === setupConfirm ? 'ok' : 'bad'
);
// Magic link
let magicOpen = $state(false);
let magicEmail = $state('');
let magicStatus = $state<{ text: string; ok: boolean } | null>(null);
// OIDC
let oidc = $state<OidcProviders>({ enabled: false });
const passwordLoginEnabled = $derived(oidc.password_login_enabled !== false);
const redirectTarget = $derived(page.url.searchParams.get('redirect') || '/files');
const matchState = $derived(
regConfirm.length === 0 ? '' : regPassword === regConfirm ? 'ok' : 'bad'
);
function csrfCookiePresent(): boolean {
return document.cookie.split('; ').some((c) => c.startsWith('oxicloud_csrf='));
}
async function onsubmit(e: SubmitEvent) {
function onPwKey(e: KeyboardEvent) {
capsOn = e.getModifierState?.('CapsLock') ?? false;
}
function onRegPwKey(e: KeyboardEvent) {
regCapsOn = e.getModifierState?.('CapsLock') ?? false;
}
function onSetupPwKey(e: KeyboardEvent) {
setupCapsOn = e.getModifierState?.('CapsLock') ?? false;
}
async function onLogin(e: SubmitEvent) {
e.preventDefault();
error = '';
busy = true;
try {
const data = await login(username, password);
// Tokens are HttpOnly cookies set by the server. Verify the browser
// actually accepted them: the non-HttpOnly CSRF cookie must be present.
if (!csrfCookiePresent()) {
error = t(
'auth.cookie_rejected',
@@ -39,6 +107,121 @@
busy = false;
}
}
async function onRegister(e: SubmitEvent) {
e.preventDefault();
regError = '';
regSuccess = '';
if (regPassword !== regConfirm) {
regError = t('auth.passwords_mismatch', 'Passwords do not match');
return;
}
busy = true;
try {
await register(regUsername, regEmail, regPassword);
regSuccess = t('auth.account_success', 'Account created. You can now sign in.');
regUsername = regEmail = regPassword = regConfirm = '';
setTimeout(() => (mode = 'login'), 2000);
} catch (err) {
regError =
err instanceof Error ? err.message : t('auth.register_error', 'Registration failed');
} finally {
busy = false;
}
}
async function onSetup(e: SubmitEvent) {
e.preventDefault();
setupError = '';
setupSuccess = '';
if (setupPassword !== setupConfirm) {
setupError = t('auth.passwords_mismatch', 'Passwords do not match');
return;
}
busy = true;
try {
await setupAdmin(setupEmail, setupPassword);
setupSuccess = t('auth.admin_success', 'Administrator created. You can now sign in.');
setupEmail = setupPassword = setupConfirm = '';
// Admin now exists — fold the setup affordance away and return to login.
setupAvailable = false;
setTimeout(() => {
mode = 'login';
setupSuccess = '';
}, 2000);
} catch (err) {
setupError =
err instanceof Error ? err.message : t('auth.admin_create_error', 'Setup failed');
} finally {
busy = false;
}
}
async function onMagicLink(e: SubmitEvent) {
e.preventDefault();
if (!magicEmail) return;
magicStatus = null;
busy = true;
try {
const result = await sendMagicLink(magicEmail);
magicStatus =
result === 'sent'
? {
text: t(
'auth.magic_sent',
'If an account exists, a sign-in link has been sent. Check your inbox.'
),
ok: true
}
: {
text: t(
'auth.magic_unavailable',
'Sign-in by email is not available on this server.'
),
ok: false
};
if (result === 'sent') magicEmail = '';
} catch {
magicStatus = { text: t('auth.magic_error', 'Something went wrong. Try again.'), ok: false };
} finally {
busy = false;
}
}
onMount(async () => {
// 1) OIDC code-exchange fallback: the IdP round-trip may land back here
// with ?oidc_code=. Exchange it for a session and redirect into the app.
const oidcCode = page.url.searchParams.get('oidc_code');
if (oidcCode) {
const user = await exchangeOidcCode(oidcCode);
if (user) {
session.user = user;
await goto(redirectTarget, { replaceState: true });
return;
}
// Exchange failed — fall through to the normal login UI.
}
// 2) Existing-session probe: if already authenticated, skip the form.
try {
const me = await fetchMe();
if (me) {
session.user = me;
await goto(redirectTarget, { replaceState: true });
return;
}
} catch {
/* probe failed — show the login page */
}
// 3) Bootstrap probe: a fresh install (no admin) must be set up first.
const [providers, status] = await Promise.all([getOidcProviders(), getAuthStatus()]);
oidc = providers;
setupAvailable = !status.initialized;
if (setupAvailable) mode = 'setup';
booting = false;
});
</script>
<svelte:head>
@@ -55,53 +238,407 @@
/>
</svg>
</div>
<div class="auth-logo-text">OxiCloud</div>
<div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div>
</div>
<h1 class="auth-title">{t('auth.sign_in', 'Sign in')}</h1>
{#if booting}
<p class="auth-subtitle">{t('common.loading', 'Loading…')}</p>
{:else}
<h1 class="auth-title">
{#if mode === 'login'}
{t('auth.sign_in', 'Sign in')}
{:else if mode === 'register'}
{t('auth.register', 'Create account')}
{:else}
{t('auth.setup_title', 'Initial setup')}
{/if}
</h1>
{#if page.url.searchParams.get('source') === 'session_expired'}
<div class="auth-error" style="display: block">
{t('auth.session_expired', 'Your session expired. Please sign in again.')}
</div>
{#if page.url.searchParams.get('source') === 'session_expired'}
<div class="auth-error" style="display: block">
{t('auth.session_expired', 'Your session expired. Please sign in again.')}
</div>
{/if}
{#if mode === 'login'}
{#if passwordLoginEnabled}
{#if error}<div class="auth-error" style="display: block" role="alert">{error}</div>{/if}
<form class="auth-form" onsubmit={onLogin} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="login-username">
{t('auth.username', 'Username or email')}
</label>
<div class="auth-input-wrap auth-input-wrap--user">
<input
id="login-username"
class="auth-input"
type="text"
bind:value={username}
autocomplete="username"
required
disabled={busy}
/>
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="login-password">{t('auth.password', 'Password')}</label
>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="login-password"
class="auth-input"
type={showPassword ? 'text' : 'password'}
bind:value={password}
onkeydown={onPwKey}
onkeyup={onPwKey}
autocomplete="current-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={showPassword}
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (showPassword = !showPassword)}
></button>
</div>
{#if capsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
{busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')}
</button>
</form>
<button class="auth-magic-toggle" onclick={() => (magicOpen = !magicOpen)}>
{t('auth.magic_prompt', 'No password? Sign in with an email link')}
</button>
{#if magicOpen}
<div class="auth-magic-reveal">
<p class="auth-hint">
{t(
'auth.magic_hint',
"No password? Enter your email and we'll send you a one-time sign-in link."
)}
</p>
<form class="auth-form" onsubmit={onMagicLink}>
<div class="auth-input-group">
<label class="auth-label" for="magic-email">
{t('auth.magic_email_label', 'Email address')}
</label>
<div class="auth-input-wrap auth-input-wrap--mail">
<input
id="magic-email"
class="auth-input"
type="email"
bind:value={magicEmail}
autocomplete="email"
placeholder={t('auth.email', 'you@example.com')}
/>
</div>
</div>
<button class="auth-button auth-button-secondary" type="submit" disabled={busy}>
{t('auth.magic_send', 'Send link')}
</button>
</form>
{#if magicStatus}
<div
class={magicStatus.ok
? 'auth-status auth-status-success'
: 'auth-status auth-status-error'}
>
{magicStatus.text}
</div>
{/if}
</div>
{/if}
{/if}
{#if oidc.enabled}
{#if passwordLoginEnabled}
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
{/if}
<a class="auth-button auth-button-oidc" href={oidc.authorize_endpoint}>
{t(
'auth.sso_login_provider',
{ provider: oidc.provider_name ?? 'SSO' },
'Sign in with {{provider}}'
)}
</a>
{/if}
{#if passwordLoginEnabled}
<div class="auth-toggle">
{t('auth.no_account', 'No account?')}
<button class="auth-toggle-link" onclick={() => (mode = 'register')}>
{t('auth.register', 'Create one')}
</button>
</div>
{/if}
{#if setupAvailable}
<div class="auth-toggle">
{t('auth.admin_setup', 'First time?')}
<button class="auth-toggle-link" onclick={() => (mode = 'setup')}>
{t('auth.setup', 'Set up administrator')}
</button>
</div>
{/if}
{:else if mode === 'register'}
{#if regError}<div class="auth-error" style="display: block" role="alert">
{regError}
</div>{/if}
{#if regSuccess}<div class="auth-success" style="display: block">{regSuccess}</div>{/if}
<form class="auth-form" onsubmit={onRegister} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="reg-username">{t('auth.username', 'Username')}</label>
<input
id="reg-username"
class="auth-input"
bind:value={regUsername}
required
disabled={busy}
/>
</div>
<div class="auth-input-group">
<label class="auth-label" for="reg-email">{t('auth.email', 'Email')}</label>
<input
id="reg-email"
class="auth-input"
type="email"
bind:value={regEmail}
required
disabled={busy}
/>
</div>
<div class="auth-input-group">
<label class="auth-label" for="reg-password">{t('auth.password', 'Password')}</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="reg-password"
class="auth-input"
type={regShowPassword ? 'text' : 'password'}
bind:value={regPassword}
onkeydown={onRegPwKey}
onkeyup={onRegPwKey}
autocomplete="new-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={regShowPassword}
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowPassword = !regShowPassword)}
></button>
</div>
{#if regCapsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
</div>
<div class="auth-input-group">
<label class="auth-label" for="reg-confirm"
>{t('auth.confirm_password', 'Confirm password')}</label
>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="reg-confirm"
class="auth-input"
type={regShowConfirm ? 'text' : 'password'}
bind:value={regConfirm}
onkeydown={onRegPwKey}
onkeyup={onRegPwKey}
autocomplete="new-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={regShowConfirm}
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowConfirm = !regShowConfirm)}
></button>
</div>
{#if matchState}
<div
class="auth-match show {matchState === 'ok' ? 'auth-match--ok' : 'auth-match--bad'}"
>
{matchState === 'ok'
? t('auth.passwords_match', 'Passwords match')
: t('auth.passwords_mismatch', "Passwords don't match")}
</div>
{/if}
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
{t('auth.register', 'Create account')}
</button>
</form>
<div class="auth-toggle">
{t('auth.have_account', 'Already have an account?')}
<button class="auth-toggle-link" onclick={() => (mode = 'login')}>
{t('auth.sign_in', 'Sign in')}
</button>
</div>
{:else}
<div class="setup-steps">
<div class="setup-step">
<div class="step-number active">1</div>
<div class="step-title active">{t('auth.setup_step1', 'Admin')}</div>
</div>
<div class="setup-step">
<div class="step-number">2</div>
<div class="step-title">{t('auth.setup_step2', 'System')}</div>
</div>
<div class="setup-step">
<div class="step-number">3</div>
<div class="step-title">{t('auth.setup_step3', 'Completed')}</div>
</div>
</div>
{#if setupError}<div class="auth-error" style="display: block" role="alert">
{setupError}
</div>{/if}
{#if setupSuccess}<div class="auth-success" style="display: block">{setupSuccess}</div>{/if}
<form class="auth-form" onsubmit={onSetup} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="setup-username">
{t('auth.admin_username', 'Administrator username')}
</label>
<div class="auth-input-wrap auth-input-wrap--user">
<input id="setup-username" class="auth-input" type="text" value="admin" readonly />
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-email">
{t('auth.admin_email', 'Administrator email')}
</label>
<div class="auth-input-wrap auth-input-wrap--mail">
<input
id="setup-email"
class="auth-input"
type="email"
bind:value={setupEmail}
autocomplete="email"
required
disabled={busy}
/>
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-password">
{t('auth.admin_password', 'Administrator password')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="setup-password"
class="auth-input"
type={setupShowPassword ? 'text' : 'password'}
bind:value={setupPassword}
onkeydown={onSetupPwKey}
onkeyup={onSetupPwKey}
autocomplete="new-password"
minlength="8"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={setupShowPassword}
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowPassword = !setupShowPassword)}
></button>
</div>
{#if setupCapsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-confirm">
{t('auth.confirm_password', 'Confirm password')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="setup-confirm"
class="auth-input"
type={setupShowConfirm ? 'text' : 'password'}
bind:value={setupConfirm}
onkeydown={onSetupPwKey}
onkeyup={onSetupPwKey}
autocomplete="new-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={setupShowConfirm}
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowConfirm = !setupShowConfirm)}
></button>
</div>
{#if setupMatchState}
<div
class="auth-match show {setupMatchState === 'ok'
? 'auth-match--ok'
: 'auth-match--bad'}"
>
{setupMatchState === 'ok'
? t('auth.passwords_match', 'Passwords match')
: t('auth.passwords_mismatch', "Passwords don't match")}
</div>
{/if}
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
{t('auth.create_admin', 'Create administrator')}
</button>
</form>
<div class="auth-toggle">
{t('auth.back_to_login', 'Already configured?')}
<button class="auth-toggle-link" onclick={() => (mode = 'login')}>
{t('auth.sign_in', 'Sign in')}
</button>
</div>
{/if}
{/if}
{#if error}
<div class="auth-error" style="display: block" role="alert">{error}</div>
{/if}
<form class="auth-form" {onsubmit} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="login-username">
{t('auth.username', 'Username or email')}
</label>
<input
id="login-username"
class="auth-input"
type="text"
bind:value={username}
autocomplete="username"
required
disabled={busy}
/>
</div>
<div class="auth-input-group">
<label class="auth-label" for="login-password">{t('auth.password', 'Password')}</label>
<input
id="login-password"
class="auth-input"
type="password"
bind:value={password}
autocomplete="current-password"
required
disabled={busy}
/>
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
{busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')}
</button>
</form>
<div class="auth-lang">
<select
aria-label={t('settings.language', 'Language')}
value={i18n.locale}
onchange={(e) => setLocale(e.currentTarget.value as Locale)}
>
{#each SUPPORTED_LOCALES as loc (loc)}
<option value={loc}>{loc}</option>
{/each}
</select>
</div>
</div>
</div>
<style>
.auth-lang {
margin-top: var(--space-5);
text-align: center;
}
.auth-lang select {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text-muted);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -3,23 +3,74 @@
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
const reason = $derived(page.url.searchParams.get('reason') ?? '');
type ErrorAction = 'retry' | 'close';
interface ErrorView {
title: string;
message: string;
actionLabel: string;
action: ErrorAction;
}
// Legacy used `?type=`; the rewrite briefly renamed it to `?reason=`. Read
// `type` first and fall back to `reason` so older links keep working.
const errorType = $derived(
page.url.searchParams.get('type') ?? page.url.searchParams.get('reason') ?? 'generic'
);
const view = $derived<ErrorView>(buildView(errorType));
function buildView(type: string): ErrorView {
switch (type) {
case 'invalid-credentials':
return {
title: t('nextcloud.error_invalid_title', 'Login Failed'),
message: t(
'nextcloud.error_invalid_body',
'Invalid username or password. Please check your credentials and try again.'
),
actionLabel: t('common.retry', 'Try Again'),
action: 'retry'
};
case 'session-expired':
return {
title: t('nextcloud.error_expired_title', 'Session Expired'),
message: t('nextcloud.error_expired_body', 'Your session has expired. Please try again.'),
actionLabel: t('nextcloud.close_window', 'Close Window'),
action: 'close'
};
case 'not-found':
return {
title: t('nextcloud.error_notfound_title', 'Not Found'),
message: t('nextcloud.error_notfound_body', 'The requested page was not found.'),
actionLabel: t('nextcloud.close_window', 'Close Window'),
action: 'close'
};
default:
return {
title: t('nextcloud.error_title', 'Error'),
message: t(
'nextcloud.error_generic_body',
'An unexpected error occurred. Please try again.'
),
actionLabel: t('nextcloud.close_window', 'Close Window'),
action: 'close'
};
}
}
function onAction() {
if (view.action === 'retry') history.back();
else window.close();
}
</script>
<svelte:head
><title>{t('nextcloud.error_title', 'Something went wrong')} · OxiCloud</title></svelte:head
>
<svelte:head><title>{view.title} · OxiCloud</title></svelte:head>
<main class="nc-status">
<Icon name="ban" class="nc-status__icon nc-status__icon--err" />
<h1>{t('nextcloud.error_title', 'Something went wrong')}</h1>
<p>
{t(
'nextcloud.error_body',
'The connection could not be completed. Please try again from your application.'
)}
</p>
{#if reason}<p class="nc-status__reason">{reason}</p>{/if}
<h1>{view.title}</h1>
<p>{view.message}</p>
<button type="button" class="nc-status__action" onclick={onAction}>{view.actionLabel}</button>
</main>
<style>
@@ -42,8 +93,13 @@
color: var(--color-danger-text);
}
.nc-status__reason {
color: var(--color-text-muted);
font-size: 0.875rem;
.nc-status__action {
margin-top: 0.5rem;
padding: 0.5rem 1.25rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
cursor: pointer;
}
</style>
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { apiFetch } from '$lib/api/client';
import { getOidcProviders } from '$lib/api/endpoints/auth';
import { t } from '$lib/i18n/index.svelte';
// Nextcloud Login Flow v2. The form does a NATIVE POST to the backend flow
@@ -16,114 +16,80 @@
let passwordLoginEnabled = $state(true);
onMount(async () => {
try {
const resp = await apiFetch('/api/auth/oidc/providers');
if (!resp.ok) return;
const info = (await resp.json()) as {
enabled?: boolean;
provider_name?: string;
password_login_enabled?: boolean;
};
if (!info.enabled) return;
oidcEnabled = true;
oidcProvider = info.provider_name || 'SSO';
passwordLoginEnabled = info.password_login_enabled !== false;
} catch {
/* OIDC not available — password-only */
}
const info = await getOidcProviders();
if (!info.enabled) return;
oidcEnabled = true;
oidcProvider = info.provider_name || 'SSO';
passwordLoginEnabled = info.password_login_enabled !== false;
});
</script>
<svelte:head><title>{t('app.title', 'OxiCloud')}</title></svelte:head>
<main class="nc">
<div class="nc__card">
<h1>{t('nextcloud.grant_title', 'Grant access')}</h1>
<div class="auth-container">
<div class="auth-panel">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="120 120 280 280" aria-hidden="true">
<path
d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z"
/>
</svg>
</div>
<div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div>
</div>
<h1 class="auth-title">{t('nextcloud.grant_title', 'Grant access')}</h1>
<p class="auth-subtitle">
{t('nextcloud.grant_subtitle', 'A Nextcloud client is requesting access to your account.')}
</p>
{#if !validToken}
<p class="nc__error">{t('nextcloud.invalid_token', 'Invalid session token.')}</p>
<div class="auth-error" style="display: block" role="alert">
{t('nextcloud.invalid_token', 'Invalid session token.')}
</div>
{:else}
{#if passwordLoginEnabled}
<form method="post" action={formAction} class="nc__form">
<label>
<span>{t('auth.username', 'Username or email')}</span>
<input name="user" type="text" autocomplete="username" required />
</label>
<label>
<span>{t('auth.password', 'Password')}</span>
<input name="password" type="password" autocomplete="current-password" required />
</label>
<button type="submit">{t('nextcloud.grant', 'Grant access')}</button>
<form class="auth-form" method="post" action={formAction}>
<div class="auth-input-group">
<label class="auth-label" for="nc-user">{t('auth.username', 'Username or email')}</label
>
<div class="auth-input-wrap auth-input-wrap--user">
<input
id="nc-user"
class="auth-input"
name="user"
type="text"
autocomplete="username"
required
/>
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="nc-password">{t('auth.password', 'Password')}</label>
<div class="auth-input-wrap auth-input-wrap--lock">
<input
id="nc-password"
class="auth-input"
name="password"
type="password"
autocomplete="current-password"
required
/>
</div>
</div>
<button class="auth-button" type="submit">{t('nextcloud.grant', 'Grant access')}</button>
</form>
{/if}
{#if oidcEnabled}
<div class="nc__oidc">
<a class="nc__sso" href={`/login/v2/flow/${token}/oidc`}>
{t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')}
</a>
</div>
{#if passwordLoginEnabled}
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
{/if}
<a class="auth-button auth-button-sso" href={`/login/v2/flow/${token}/oidc`}>
{t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')}
</a>
{/if}
{/if}
</div>
</main>
<style>
.nc {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1rem;
background: var(--color-bg-page);
}
.nc__card {
width: min(92vw, 22rem);
display: flex;
flex-direction: column;
gap: 1rem;
padding: 2rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.nc__form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
}
input {
padding: 0.5rem 0.625rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
button,
.nc__sso {
display: inline-block;
text-align: center;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
text-decoration: none;
cursor: pointer;
}
.nc__error {
color: var(--color-danger-text);
}
</style>
</div>
@@ -1,6 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
function closeWindow() {
window.close();
}
onMount(() => {
// Mirror the legacy flow: auto-close the popup shortly after success so
// the user is returned to their Nextcloud client without an extra click.
const timer = setTimeout(closeWindow, 3000);
return () => clearTimeout(timer);
});
</script>
<svelte:head><title>{t('nextcloud.success_title', 'Access granted')} · OxiCloud</title></svelte:head
@@ -10,6 +22,9 @@
<Icon name="check" class="nc-status__icon nc-status__icon--ok" />
<h1>{t('nextcloud.success_title', 'Access granted')}</h1>
<p>{t('nextcloud.success_body', 'You can now return to your application — it is connected.')}</p>
<button type="button" class="nc-status__action" onclick={closeWindow}>
{t('nextcloud.close_window', 'Close Window')}
</button>
</main>
<style>
@@ -31,4 +46,14 @@
:global(.nc-status__icon--ok) {
color: var(--color-success-text);
}
.nc-status__action {
margin-top: 0.5rem;
padding: 0.5rem 1.25rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
cursor: pointer;
}
</style>
+785 -16
View File
@@ -1,9 +1,19 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchPhotos } from '$lib/api/endpoints/photos';
import { fileInlineUrl, fileThumbnailUrl } from '$lib/api/endpoints/files';
import {
batchTrash,
fetchFileMetadata,
fetchPhotos,
uploadThumbnail,
type FileMetadata
} from '$lib/api/endpoints/photos';
import { addFavorite } from '$lib/api/endpoints/favorites';
import { deleteFile, fileDownloadUrl, fileInlineUrl } from '$lib/api/endpoints/files';
import type { FileItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
let items = $state<FileItem[]>([]);
let cursor = $state<string | null>(null);
@@ -12,6 +22,72 @@
let error = $state<string | null>(null);
let sentinel = $state<HTMLElement | null>(null);
type GroupMode = 'day' | 'month' | 'year';
const GROUP_KEY = 'oxicloud-photos-group';
let groupMode = $state<GroupMode>('month');
let selected = $state<Set<string>>(new Set());
let lightbox = $state(-1); // index into `items`, -1 = closed
/** Client-generated video frame thumbnails (file id → data/URL). */
let videoThumbs = $state<Record<string, string>>({});
function isVideo(p: FileItem): boolean {
return (p.mime_type ?? '').startsWith('video/');
}
/** EXIF-aware timestamp (seconds → ms), matching the OLD grouping logic. */
function ts(p: FileItem): number {
const v = p.sort_date || p.created_at || 0;
return v < 1e12 ? v * 1000 : v;
}
function bucketKey(d: Date): string {
const y = d.getFullYear();
if (groupMode === 'year') return `${y}`;
const m = `${d.getMonth() + 1}`.padStart(2, '0');
if (groupMode === 'month') return `${y}-${m}`;
return `${y}-${m}-${`${d.getDate()}`.padStart(2, '0')}`;
}
function bucketLabel(d: Date): string {
if (groupMode === 'year') return `${d.getFullYear()}`;
if (groupMode === 'month')
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
return d.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
const groups = $derived.by(() => {
const out: Array<{ key: string; label: string; photos: FileItem[] }> = [];
const index = new Map<string, number>();
for (const p of items) {
const d = new Date(ts(p));
const key = bucketKey(d);
let i = index.get(key);
if (i === undefined) {
i = out.length;
index.set(key, i);
out.push({ key, label: bucketLabel(d), photos: [] });
}
out[i].photos.push(p);
}
return out;
});
function iconUrl(id: string): string {
return `/api/files/${id}/thumbnail/icon`;
}
function previewUrl(id: string): string {
return `/api/files/${id}/thumbnail/preview`;
}
function largeUrl(id: string): string {
return `/api/files/${id}/thumbnail/large`;
}
async function loadMore() {
if (loading || exhausted) return;
loading = true;
@@ -29,7 +105,290 @@
}
}
function setGroupMode(m: GroupMode) {
if (groupMode === m) return;
groupMode = m;
if (typeof localStorage !== 'undefined') localStorage.setItem(GROUP_KEY, m);
}
function toggle(id: string) {
const n = new Set(selected);
if (n.has(id)) n.delete(id);
else n.add(id);
selected = n;
}
/** A plain tile click toggles selection once anything is selected, else opens the lightbox. */
function onTileClick(p: FileItem) {
if (selected.size > 0) toggle(p.id);
else openLightbox(p);
}
function downloadSelected() {
for (const id of selected) {
const a = document.createElement('a');
a.href = fileDownloadUrl(id);
a.download = '';
document.body.appendChild(a);
a.click();
a.remove();
}
}
async function trashSelected() {
const ids = [...selected];
const ok = await confirmDialog({
title: t('photos.delete', 'Delete photos'),
message: t('photos.confirm_delete', { n: ids.length }, 'Move {{n}} photos to trash?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
const trashed = await batchTrash(ids);
if (trashed.size > 0) {
items = items.filter((p) => !trashed.has(p.id));
const n = new Set(selected);
for (const id of trashed) n.delete(id);
selected = n;
}
if (trashed.size < ids.length) {
ui.notify(
t(
'photos.trash_partial',
{ ok: trashed.size, total: ids.length },
'{{ok}} of {{total}} moved to trash.'
),
'warning'
);
} else {
ui.notify(t('photos.trashed', { n: trashed.size }, '{{n}} moved to trash.'), 'success');
}
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
// ── Client-side video thumbnail generation ──────────────────────────────
// When the server has no thumbnail for a video tile the <img> errors; we
// then extract a frame with the browser's native decoder and upload it.
async function generateVideoThumb(file: FileItem) {
if (videoThumbs[file.id]) return;
try {
const bitmap = await frameFromVideo(fileInlineUrl(file.id));
const SIZES: Array<['icon' | 'preview' | 'large', number, number]> = [
['icon', 150, 150],
['preview', 400, 400],
['large', 800, 800]
];
let previewData = '';
for (const [size, w, h] of SIZES) {
const blob = await bitmapToBlob(bitmap, w, h);
if (size === 'preview') previewData = await blobToDataUrl(blob);
await uploadThumbnail(file.id, size, blob).catch(() => {});
}
if (previewData) videoThumbs = { ...videoThumbs, [file.id]: previewData };
} catch {
// Keep the generic play badge on failure.
}
}
function frameFromVideo(src: string): Promise<ImageBitmap> {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.src = src;
video.muted = true;
video.preload = 'metadata';
video.onloadedmetadata = () => {
video.currentTime = (video.duration || 3) / 3;
};
video.onseeked = async () => {
try {
const bitmap = await createImageBitmap(video);
video.removeAttribute('src');
video.load();
resolve(bitmap);
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
};
video.onerror = () => reject(new Error('video frame extraction failed'));
});
}
async function bitmapToBlob(bitmap: ImageBitmap, tw: number, th: number): Promise<Blob> {
const ratio = bitmap.width / bitmap.height;
const target = tw / th;
const w = ratio > target ? tw : Math.round(th * ratio);
const h = ratio > target ? Math.round(tw / ratio) : th;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, w, h);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('canvas toBlob failed'))),
'image/jpeg',
0.8
);
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('blob read failed'));
reader.readAsDataURL(blob);
});
}
// ── Lightbox ─────────────────────────────────────────────────────────────
let lbShowingOriginal = $state(false);
let lbFullResBusy = $state(false);
let lbMeta = $state('');
let lbFavorited = $state(false);
/** Token guarding against stale async loads during rapid prev/next. */
let lbGeneration = 0;
const lbItem = $derived(lightbox >= 0 ? (items[lightbox] ?? null) : null);
function baseMeta(p: FileItem): string {
const dateStr = new Date(ts(p)).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return p.size_formatted ? `${dateStr} · ${p.size_formatted}` : dateStr;
}
function applyMetadata(p: FileItem, md: FileMetadata) {
const parts = [baseMeta(p)];
if (md.camera_make || md.camera_model) {
parts.push([md.camera_make, md.camera_model].filter(Boolean).join(' '));
}
if (md.width && md.height) parts.push(`${md.width}×${md.height}`);
lbMeta = parts.join(' · ');
}
function openLightbox(p: FileItem) {
lightbox = items.findIndex((x) => x.id === p.id);
}
/** Reset per-item lightbox state and kick off metadata + neighbour preload. */
function showLightboxItem(p: FileItem) {
const generation = ++lbGeneration;
lbShowingOriginal = p.mime_type === 'image/gif';
lbFullResBusy = false;
lbFavorited = false;
lbMeta = baseMeta(p);
preloadNeighbors();
void fetchFileMetadata(p.id).then((md) => {
if (md && generation === lbGeneration) applyMetadata(p, md);
});
}
// Re-run per-item setup whenever the visible lightbox item changes.
$effect(() => {
if (lbItem) showLightboxItem(lbItem);
});
function preloadNeighbors() {
for (const i of [lightbox - 1, lightbox + 1]) {
const it = items[i];
if (it && !isVideo(it)) {
const pre = new Image();
pre.src = largeUrl(it.id);
}
}
}
/** The image src to display: large thumbnail first, original on expand/GIF. */
const lbImgSrc = $derived(
lbItem ? (lbShowingOriginal ? fileInlineUrl(lbItem.id) : largeUrl(lbItem.id)) : ''
);
function onLbImgError() {
if (!lbItem) return;
// Thumbnail missing → fall back to the original; original failing is terminal.
if (!lbShowingOriginal) {
lbShowingOriginal = true;
}
}
function onLbImgLoad() {
lbFullResBusy = false;
}
function expandFullRes() {
if (!lbItem || lbShowingOriginal) return;
lbShowingOriginal = true;
lbFullResBusy = true;
}
function lbDownload() {
if (!lbItem) return;
const a = document.createElement('a');
a.href = fileDownloadUrl(lbItem.id);
a.download = lbItem.name;
document.body.appendChild(a);
a.click();
a.remove();
}
async function lbToggleFavorite() {
if (!lbItem) return;
try {
await addFavorite('file', lbItem.id);
lbFavorited = !lbFavorited;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function lbDelete() {
if (!lbItem) return;
const target = lbItem;
const ok = await confirmDialog({
title: t('photos.delete', 'Delete photo'),
message: t('photos.confirm_delete_one', { name: target.name }, 'Delete {{name}}?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
await deleteFile(target.id);
const at = items.findIndex((x) => x.id === target.id);
items = items.filter((x) => x.id !== target.id);
if (items.length === 0) {
lightbox = -1;
} else {
lightbox = Math.min(at, items.length - 1);
}
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
function lbPrev() {
if (lightbox > 0) lightbox -= 1;
}
function lbNext() {
if (lightbox >= 0 && lightbox < items.length - 1) lightbox += 1;
}
function onKeydown(e: KeyboardEvent) {
if (lightbox < 0) return;
if (e.key === 'Escape') lightbox = -1;
else if (e.key === 'ArrowLeft') lbPrev();
else if (e.key === 'ArrowRight') lbNext();
}
onMount(() => {
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem(GROUP_KEY) : null;
if (saved === 'day' || saved === 'month' || saved === 'year') groupMode = saved;
void loadMore();
if (!sentinel) return;
const obs = new IntersectionObserver(
@@ -41,62 +400,326 @@
obs.observe(sentinel);
return () => obs.disconnect();
});
const MODES: GroupMode[] = ['day', 'month', 'year'];
</script>
<svelte:head><title>{t('nav.photos', 'Photos')} · OxiCloud</title></svelte:head>
<svelte:window onkeydown={onKeydown} />
<h1 class="page-title">{t('nav.photos', 'Photos')}</h1>
<div class="page-sticky-header photos-head">
<h1 class="page-title">{t('nav.photos', 'Photos')}</h1>
<div class="seg" role="group" aria-label={t('photos.group_by', 'Group by')}>
{#each MODES as m (m)}
<button class="seg__btn" class:active={groupMode === m} onclick={() => setGroupMode(m)}>
{t(`photos.${m}`, m)}
</button>
{/each}
</div>
</div>
{#if selected.size > 0}
<div class="batch-bar">
<span>{t('files.selected_count', { n: selected.size }, '{{n}} selected')}</span>
<div class="batch-bar__actions">
<button class="btn btn-secondary" onclick={downloadSelected}
>{t('common.download', 'Download')}</button
>
<button class="btn btn-secondary" onclick={() => (selected = new Set())}
>{t('common.clear', 'Clear')}</button
>
<button class="btn btn-danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</button>
</div>
</div>
{/if}
{#if error}
<p class="status status--error" role="alert">{error}</p>
{:else if items.length === 0 && exhausted}
<p class="status">{t('photos.empty', 'No photos yet.')}</p>
<div class="empty-state">
<Icon name="images" class="empty-state__icon" />
<p class="empty-state__title">{t('photos.empty', 'No photos yet.')}</p>
<p class="empty-state__hint">
{t('photos.empty_hint', 'Photos and videos you upload will appear here, grouped by date.')}
</p>
</div>
{:else}
<ul class="photos">
{#each items as photo (photo.id)}
<li class="photos__cell">
<a href={fileInlineUrl(photo.id)} target="_blank" rel="noreferrer">
<img src={fileThumbnailUrl(photo.id)} alt={photo.name} loading="lazy" decoding="async" />
</a>
</li>
{/each}
</ul>
{#each groups as group (group.key)}
<h2 class="photos-group">
{group.label} <span class="photos-group__count">{group.photos.length}</span>
</h2>
<ul class="photos">
{#each group.photos as photo (photo.id)}
<li class="photos__cell" class:selected={selected.has(photo.id)}>
<button class="photos__open" onclick={() => onTileClick(photo)}>
{#if videoThumbs[photo.id]}
<img src={videoThumbs[photo.id]} alt={photo.name} loading="lazy" decoding="async" />
{:else}
<img
src={previewUrl(photo.id)}
srcset={`${iconUrl(photo.id)} 150w, ${previewUrl(photo.id)} 400w, ${largeUrl(photo.id)} 800w`}
sizes="(max-width: 768px) 33vw, 200px"
alt={photo.name}
loading="lazy"
decoding="async"
onerror={isVideo(photo) ? () => generateVideoThumb(photo) : undefined}
/>
{/if}
{#if isVideo(photo)}
<span class="photos__video-badge" aria-hidden="true"><Icon name="play" /></span>
{/if}
</button>
<button
class="photos__check"
class:on={selected.has(photo.id)}
aria-label={t('common.select', 'Select')}
onclick={() => toggle(photo.id)}
>
<Icon name="check" />
</button>
</li>
{/each}
</ul>
{/each}
{/if}
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
{#if loading}<p class="status">{t('common.loading', 'Loading…')}</p>{/if}
{#if lbItem}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="lb"
role="dialog"
aria-modal="true"
aria-label={lbItem.name}
tabindex="-1"
onclick={(e) => e.target === e.currentTarget && (lightbox = -1)}
>
<div class="lb__info">
<div class="lb__filename">{lbItem.name}</div>
<div class="lb__meta">{lbMeta}</div>
</div>
<button
class="lb__close"
aria-label={t('common.close', 'Close')}
onclick={() => (lightbox = -1)}>×</button
>
<button
class="lb__nav lb__nav--prev"
aria-label={t('common.previous', 'Previous')}
disabled={lightbox === 0}
onclick={(e) => {
e.stopPropagation();
lbPrev();
}}><Icon name="chevron-left" /></button
>
<div class="lb__content">
{#if isVideo(lbItem)}
{#key lbItem.id}
<video class="lb__media" controls autoplay poster={largeUrl(lbItem.id)}>
<source src={fileInlineUrl(lbItem.id)} type={lbItem.mime_type} />
</video>
{/key}
{:else}
<img
class="lb__media"
src={lbImgSrc}
alt={lbItem.name}
onload={onLbImgLoad}
onerror={onLbImgError}
/>
{/if}
</div>
<button
class="lb__nav lb__nav--next"
aria-label={t('common.next', 'Next')}
disabled={lightbox === items.length - 1}
onclick={(e) => {
e.stopPropagation();
lbNext();
}}><Icon name="chevron-right" /></button
>
<div class="lb__toolbar">
{#if !isVideo(lbItem) && lbItem.mime_type !== 'image/gif' && !lbShowingOriginal}
<button
class="lb__tool"
title={t('photos.full_resolution', 'Full resolution')}
disabled={lbFullResBusy}
onclick={expandFullRes}><Icon name={lbFullResBusy ? 'spinner' : 'expand'} /></button
>
{/if}
<button class="lb__tool" title={t('common.download', 'Download')} onclick={lbDownload}
><Icon name="download" /></button
>
<button
class="lb__tool"
class:active={lbFavorited}
title={t('common.favorite', 'Favorite')}
onclick={lbToggleFavorite}><Icon name={lbFavorited ? 'star' : 'star-outline'} /></button
>
<button class="lb__tool" title={t('common.delete', 'Delete')} onclick={lbDelete}
><Icon name="trash" /></button
>
</div>
<div class="lb__counter">{lightbox + 1} / {items.length}</div>
</div>
{/if}
<style>
.photos-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 1rem 1rem 0;
}
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.seg {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.seg__btn {
padding: var(--space-2) var(--space-3);
border: none;
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
text-transform: capitalize;
}
.seg__btn.active {
background: var(--color-accent);
color: var(--color-on-accent);
}
.batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin: var(--space-3) 1rem 0;
padding: var(--space-2) var(--space-3);
background: var(--color-accent-tint, var(--color-bg-hover));
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.batch-bar__actions {
display: flex;
gap: var(--space-2);
}
.photos-group {
margin: var(--space-4) 0 var(--space-2);
padding: 0 1rem;
font-size: 1rem;
color: var(--color-text-heading);
}
.photos-group__count {
color: var(--color-text-muted);
font-size: var(--text-sm);
font-weight: var(--weight-normal);
}
.photos {
list-style: none;
margin: 0;
padding: 1rem;
padding: 0 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.25rem;
}
.photos__cell {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--color-bg-muted);
}
.photos__cell img {
.photos__cell.selected {
outline: 3px solid var(--color-accent);
outline-offset: -3px;
}
.photos__open {
display: block;
width: 100%;
height: 100%;
border: none;
padding: 0;
cursor: pointer;
background: none;
}
.photos__open img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photos__video-badge {
position: absolute;
right: 6px;
bottom: 6px;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--color-scrim-control);
color: var(--color-on-accent);
display: grid;
place-items: center;
font-size: 0.7rem;
pointer-events: none;
}
.photos__check {
position: absolute;
top: 6px;
left: 6px;
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid var(--color-on-accent);
background: var(--color-scrim-control);
color: transparent;
display: grid;
place-items: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.photos__cell:hover .photos__check,
.photos__check.on {
opacity: 1;
}
.photos__check.on {
background: var(--color-accent);
color: var(--color-on-accent);
border-color: var(--color-accent);
}
.status {
text-align: center;
color: var(--color-text-muted);
@@ -107,7 +730,153 @@
color: var(--color-danger-text);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
text-align: center;
padding: 4rem 1rem;
color: var(--color-text-muted);
}
.empty-state :global(.empty-state__icon) {
font-size: 3rem;
color: var(--color-text-muted);
}
.empty-state__title {
margin: 0;
font-size: 1.1rem;
color: var(--color-text-heading);
}
.empty-state__hint {
margin: 0;
max-width: 28rem;
}
.sentinel {
height: 1px;
}
.lb {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--color-lightbox-overlay);
display: flex;
align-items: center;
justify-content: center;
}
.lb__content {
max-width: 92vw;
max-height: 88vh;
display: flex;
align-items: center;
justify-content: center;
}
.lb__media {
max-width: 92vw;
max-height: 88vh;
object-fit: contain;
}
.lb__info {
position: absolute;
top: 1rem;
left: 1rem;
color: var(--color-on-accent);
max-width: 60vw;
}
.lb__filename {
font-weight: var(--weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb__meta {
font-size: var(--text-sm);
opacity: 0.8;
}
.lb__close {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 2rem;
line-height: 1;
background: none;
border: none;
color: var(--color-on-accent);
cursor: pointer;
}
.lb__nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 2rem;
background: none;
border: none;
color: var(--color-on-accent);
cursor: pointer;
padding: 1rem;
}
.lb__nav:disabled {
opacity: 0.3;
cursor: default;
}
.lb__nav--prev {
left: 0.5rem;
}
.lb__nav--next {
right: 0.5rem;
}
.lb__toolbar {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: var(--space-2);
}
.lb__tool {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: var(--color-scrim-control);
color: var(--color-on-accent);
cursor: pointer;
display: grid;
place-items: center;
}
.lb__tool:disabled {
opacity: 0.5;
cursor: default;
}
.lb__tool.active {
color: var(--color-accent);
}
.lb__counter {
position: absolute;
bottom: 1rem;
right: 1rem;
color: var(--color-on-accent);
font-size: var(--text-sm);
opacity: 0.8;
}
</style>
File diff suppressed because it is too large Load Diff
+340 -44
View File
@@ -1,85 +1,381 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
import {
addFavorite,
dateBucket,
fetchFavoritesPage,
removeFavorite,
resolveOwnerName,
sizeBucket,
typeLabel
} from '$lib/api/endpoints/favorites';
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files';
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
import type { FileItem, ItemType } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import FileViewer from '$lib/components/FileViewer.svelte';
import MoveDialog from '$lib/components/MoveDialog.svelte';
import ShareDialog from '$lib/components/ShareDialog.svelte';
import ResourceList, {
type ContextAction,
type GroupByDef,
type ResourceEntry
} from '$lib/components/ResourceList.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<RecentResourceItem[]>([]);
let raw = $state<RecentResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
let groupBy = $state('');
let reversed = $state(false);
let ownerNames = $state<Record<string, string>>({});
let favoriteIds = $state<Set<string>>(new Set());
async function load(reset = false) {
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
const entries = $derived(
raw.map((it): ResourceEntry => {
const isFile = it.resource_type === 'file';
const ownerId = it.resource.owner_id ?? null;
return {
id: it.resource.id,
name: it.resource.name,
kind: it.resource_type,
iconClass: it.resource.icon_class,
path: it.resource.path,
size: isFile ? (it.resource as FileItem).size : null,
date: it.accessed_at,
ownerId,
ownerName: ownerId ? (ownerNames[ownerId] ?? null) : null,
isFavorite: favoriteIds.has(it.resource.id),
category: isFile ? it.resource.category : 'Folder',
modifiedAt: it.resource.modified_at
};
})
);
const groupBys: GroupByDef[] = [
{ key: '', label: t('files.name', 'Name'), orderBy: 'name' },
{
key: 'owner',
label: t('groupby.owner', 'Owner'),
orderBy: 'owner',
bucketOf: (e) => e.ownerId ?? null,
labelOf: (id) => ownerNames[id] ?? id
},
{
key: 'type',
label: t('groupby.type', 'Type'),
orderBy: 'type',
bucketOf: (e) => e.category ?? 'other',
labelOf: (k) => typeLabel(k)
},
{
key: 'size',
label: t('groupby.size', 'Size'),
orderBy: 'size',
bucketOf: (e) => sizeBucket(e.kind === 'folder' ? null : e.size)
},
{
key: 'accessedAt',
label: t('groupby.accessedAt', 'Accessed date'),
orderBy: 'accessed_at',
bucketOf: (e) => dateBucket(e.date)
},
{
key: 'modifiedAt',
label: t('groupby.modifiedAt', 'Modified date'),
orderBy: 'modified_at',
bucketOf: (e) => dateBucket(e.modifiedAt)
}
];
async function resolveOwners(items: RecentResourceItem[]) {
const ids = [
...new Set(items.map((i) => i.resource.owner_id).filter((id): id is string => !!id))
];
await Promise.all(
ids.map(async (id) => {
if (ownerNames[id]) return;
const name = await resolveOwnerName(id);
ownerNames = { ...ownerNames, [id]: name };
})
);
}
async function loadFavoriteIds() {
try {
const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] });
favoriteIds = new Set(favs.items.map((f) => f.resource.id));
} catch {
// non-fatal — stars just default to off
}
}
// Recent defaults to most-recently-accessed first (accessed_at DESC).
async function load(reset = false, orderBy = 'accessed_at', rev = reversed) {
loading = true;
error = null;
try {
const page = await fetchRecentPage({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
const page = await fetchRecentPage({
cursor: reset ? undefined : cursor,
orderBy,
reverse: rev,
resourceTypes: ['file', 'folder']
});
raw = reset ? page.items : [...raw, ...page.items];
cursor = page.next_cursor;
void resolveOwners(page.items);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
console.error('recent: load error', e);
error = t('errors_loadFailed', 'Failed to load items');
} finally {
loading = false;
}
}
function orderByForGroup(): string {
return groupBys.find((g) => g.key === groupBy)?.orderBy ?? 'accessed_at';
}
let viewerOpen = $state(false);
let viewerFile = $state<FileItem | null>(null);
function open(entry: ResourceEntry) {
if (entry.kind === 'folder') {
goto(`/files/${entry.id}`);
return;
}
const item = byId.get(entry.id);
if (item) {
viewerFile = item.resource as FileItem;
viewerOpen = true;
}
}
async function toggleFavorite(entry: ResourceEntry) {
const isFav = favoriteIds.has(entry.id);
const next = new Set(favoriteIds);
if (isFav) next.delete(entry.id);
else next.add(entry.id);
favoriteIds = next;
try {
if (isFav) await removeFavorite(entry.kind, entry.id);
else await addFavorite(entry.kind, entry.id);
} catch (e) {
// revert on failure
favoriteIds = isFav
? new Set([...favoriteIds, entry.id])
: new Set([...favoriteIds].filter((id) => id !== entry.id));
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function clearAll() {
const ok = await confirmDialog({
title: t('recent.clear', 'Clear recent'),
message: t('recent.confirm_clear', 'Clear your recent items?'),
confirmText: t('recent.clear', 'Clear recent')
});
if (!ok) return;
try {
await clearRecent();
items = [];
raw = [];
cursor = undefined;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
onMount(() => load(true));
// ── Context-menu actions ──────────────────────────────────────────────────
let moveOpen = $state(false);
let moveTarget = $state<{ id: string; name: string; kind: ItemType } | null>(null);
let moveItems = $state<{ id: string; name: string; kind: ItemType }[] | null>(null);
let shareOpen = $state(false);
let shareTarget = $state<{ id: string; name: string; kind: ItemType } | null>(null);
async function rename(entry: ResourceEntry) {
const name = await promptDialog({
title: t('common.rename', 'Rename'),
defaultValue: entry.name,
confirmText: t('common.rename', 'Rename')
});
if (!name || name === entry.name) return;
try {
if (entry.kind === 'file') await renameFile(entry.id, name);
else await renameFolder(entry.id, name);
await load(true, orderByForGroup());
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function remove(entry: ResourceEntry) {
const ok = await confirmDialog({
title: t('common.delete', 'Delete'),
message: t('files.confirm_delete', { name: entry.name }, 'Delete "{{name}}"?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
if (entry.kind === 'file') await deleteFile(entry.id);
else await deleteFolder(entry.id);
raw = raw.filter((i) => i.resource.id !== entry.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
function downloadEntry(entry: ResourceEntry) {
if (entry.kind !== 'file') return;
const a = document.createElement('a');
a.href = fileDownloadUrl(entry.id);
a.download = entry.name;
document.body.appendChild(a);
a.click();
a.remove();
}
const contextActions: ContextAction[] = [
{
key: 'download',
label: t('common.download', 'Download'),
icon: 'download',
run: downloadEntry
},
{
key: 'share',
label: t('files.share', 'Share'),
icon: 'share-alt',
run: (e) => {
shareTarget = { id: e.id, name: e.name, kind: e.kind };
shareOpen = true;
}
},
{
key: 'move',
label: t('files.move', 'Move'),
icon: 'arrows-alt',
run: (e) => {
moveItems = null;
moveTarget = { id: e.id, name: e.name, kind: e.kind };
moveOpen = true;
}
},
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
];
// ── Selection + batch ─────────────────────────────────────────────────────
let selectedIds = $state<Set<string>>(new Set());
const selectedEntries = $derived(entries.filter((e) => selectedIds.has(e.id)));
function batchTargets() {
return selectedEntries.map((e) => ({ id: e.id, name: e.name, kind: e.kind }));
}
function batchDownload() {
for (const e of selectedEntries) downloadEntry(e);
}
async function batchDelete() {
const ok = await confirmDialog({
title: t('common.delete', 'Delete'),
message: t(
'files.confirm_delete_n',
{ count: selectedEntries.length },
'Delete {{count}} item(s)?'
),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
await Promise.all(
selectedEntries.map((e) => (e.kind === 'file' ? deleteFile(e.id) : deleteFolder(e.id)))
);
const removed = new Set(selectedEntries.map((e) => e.id));
raw = raw.filter((i) => !removed.has(i.resource.id));
selectedIds = new Set();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
onMount(() => {
void loadFavoriteIds();
void load(true);
});
</script>
<svelte:head><title>{t('nav.recent', 'Recent')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.recent', 'Recent')}</h1>
<ResourceListShell
<ResourceList
title={t('nav.recent', 'Recent')}
items={entries}
{loading}
{error}
empty={items.length === 0}
emptyText={t('recent.empty', 'No recent items.')}
emptyIcon="clock"
emptyText={t('recent.empty_state', 'No recent files')}
emptyHint={t('recent.empty_hint', 'Files you open will appear here')}
hasMore={!!cursor}
onloadmore={() => load(false)}
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={toggleFavorite}
showOwner
selectable
{contextActions}
{groupBys}
bind:groupBy
bind:reversed
onreload={(orderBy, rev) => {
cursor = undefined;
load(true, orderBy, rev);
}}
onselectionchange={(ids) => (selectedIds = ids)}
>
{#snippet toolbar()}
{#if items.length > 0}
<button class="link-btn" onclick={clearAll}>{t('recent.clear', 'Clear')}</button>
{#if entries.length > 0}
<button class="btn btn-secondary" onclick={clearAll}>
<Icon name="broom" />
{t('recent.clear', 'Clear recent')}
</button>
{/if}
{/snippet}
{#snippet batchToolbar()}
<button class="btn btn-secondary" onclick={batchDownload}>
<Icon name="download" />
{t('common.download', 'Download')}
</button>
<button
class="btn btn-secondary"
onclick={() => {
moveTarget = null;
moveItems = batchTargets();
moveOpen = true;
}}><Icon name="arrows-alt" /> {t('files.move', 'Move')}</button
>
<button class="btn btn-danger" onclick={batchDelete}>
<Icon name="trash" />
{t('common.delete', 'Delete')}
</button>
{/snippet}
</ResourceList>
{#each items as item (item.resource.id + item.accessed_at)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.resource.path}
date={formatDate(item.accessed_at)}
/>
{/each}
</ResourceListShell>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
}
</style>
<FileViewer bind:open={viewerOpen} file={viewerFile} />
<MoveDialog
bind:open={moveOpen}
item={moveTarget}
items={moveItems}
onmoved={() => {
selectedIds = new Set();
load(true, orderByForGroup());
}}
/>
<ShareDialog bind:open={shareOpen} item={shareTarget} />
+433 -25
View File
@@ -14,38 +14,87 @@
} from '$lib/api/endpoints/share';
import { t } from '$lib/i18n/index.svelte';
type State = 'loading' | 'password' | 'expired' | 'file' | 'folder';
type State = 'loading' | 'password' | 'expired' | 'invalid' | 'file' | 'folder';
type Crumb = { id?: string; name: string };
type ViewMode = 'grid' | 'list';
const VIEW_KEY = 'oxicloud_share_view';
const token = $derived(page.params.token ?? '');
let view = $state<State>('loading');
let meta = $state<ShareMeta | null>(null);
let listing = $state<ShareListing | null>(null);
let folderId = $state<string | undefined>(undefined);
let folderName = $state<string>('');
let crumbs = $state<Crumb[]>([]);
let pwInput = $state('');
let pwError = $state('');
let busy = $state(false);
let viewMode = $state<ViewMode>('grid');
// Lightbox over the media files in the current folder
let lightbox = $state(-1);
function mediaKind(mime: string | undefined): 'image' | 'video' | null {
const m = (mime ?? '').toLowerCase();
if (m.startsWith('image/')) return 'image';
if (m.startsWith('video/')) return 'video';
return null;
}
const mediaFiles = $derived(
(listing?.files ?? []).filter((f) => mediaKind(f.mime_type) !== null)
);
function setViewMode(mode: ViewMode) {
viewMode = mode;
try {
localStorage.setItem(VIEW_KEY, mode);
} catch {
/* storage unavailable — keep in-memory only */
}
}
async function loadMeta() {
view = 'loading';
// Guard a missing/blank token before hitting the API.
if (!token) {
view = 'invalid';
return;
}
try {
const r = await getShareMeta(token);
if (r.status === 'password') {
view = 'password';
} else if (r.status === 'expired') {
view = 'expired';
} else if (r.status === 'invalid') {
view = 'invalid';
} else {
meta = r.data;
if (r.data.item_type === 'folder') await openFolder(undefined, r.data.item_name);
else view = 'file';
if (r.data.item_type === 'folder') {
crumbs = [{ name: r.data.item_name }];
// Deep-link support: honour an initial #folder=<id> hash.
await openFolder(hashFolderId(), undefined, false);
} else view = 'file';
}
} catch {
view = 'expired';
}
}
async function openFolder(id: string | undefined, name: string) {
/** Parse the `#folder=<id>` fragment from the URL, if present. */
function hashFolderId(): string | undefined {
if (typeof location === 'undefined') return undefined;
const m = location.hash.match(/[#&]folder=([A-Za-z0-9-]{1,64})/);
return m ? m[1] : undefined;
}
/**
* Load a folder's contents. When `crumb` is given, push it onto the trail.
* `pushHistory` controls whether we sync the URL hash + push a history entry
* (true for user navigation, false when restoring from popstate / deep link).
*/
async function openFolder(id: string | undefined, crumb?: Crumb, pushHistory = false) {
const r = await getShareContents(token, id);
if (r.status === 'password') {
view = 'password';
@@ -57,8 +106,109 @@
}
listing = r.data;
folderId = id;
folderName = name;
if (crumb) crumbs = [...crumbs, crumb];
lightbox = -1;
view = 'folder';
if (pushHistory && typeof history !== 'undefined') {
const hash = id ? `#folder=${encodeURIComponent(id)}` : '';
history.pushState({ folderId: id }, '', location.pathname + location.search + hash);
}
}
/** Navigate to a breadcrumb at depth `index` (0 = share root). */
async function gotoCrumb(index: number) {
const target = crumbs[index];
crumbs = crumbs.slice(0, index + 1);
await openFolder(target.id, undefined, true);
}
/** Browser back/forward — re-resolve the folder from the popped state/hash. */
async function onPopState() {
if (view !== 'folder') return;
await openFolder(hashFolderId(), undefined, false);
}
/** Append a cache-busting query param to retry a failed media load once. */
function retrySrc(original: string): string {
const sep = original.indexOf('?') === -1 ? '?' : '&';
return `${original}${sep}_r=${Date.now()}`;
}
/**
* Lazy video poster: defer loading until near the viewport, then seek a few
* frames in to render a thumbnail. Retries once with cache-busting on error.
* Ported from publicShare.js wireLazyVideos().
*/
function lazyVideo(node: HTMLVideoElement, src: string) {
let retried = false;
const start = () => {
node.addEventListener(
'loadedmetadata',
() => {
const at = Math.min(0.1, (node.duration || 1) * 0.1);
try {
node.currentTime = at;
} catch {
/* seeking unsupported */
}
},
{ once: true }
);
node.addEventListener(
'error',
() => {
if (retried) return;
retried = true;
setTimeout(() => (node.src = retrySrc(src)), 250);
},
{ once: true }
);
node.src = src;
};
let obs: IntersectionObserver | null = null;
if (typeof IntersectionObserver !== 'undefined') {
obs = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
start();
obs?.unobserve(node);
}
}
},
{ rootMargin: '300px' }
);
obs.observe(node);
} else {
start();
}
return { destroy: () => obs?.disconnect() };
}
/** Retry a failed image load once with cache-busting. Ported from wireImageRetry(). */
function imageRetry(node: HTMLImageElement) {
let retried = false;
const onError = () => {
if (retried) return;
retried = true;
const original = node.src;
setTimeout(() => (node.src = retrySrc(original)), 250);
};
node.addEventListener('error', onError);
return { destroy: () => node.removeEventListener('error', onError) };
}
function lbPrev() {
if (lightbox > 0) lightbox -= 1;
}
function lbNext() {
if (lightbox >= 0 && lightbox < mediaFiles.length - 1) lightbox += 1;
}
function onKeydown(e: KeyboardEvent) {
if (lightbox < 0) return;
if (e.key === 'Escape') lightbox = -1;
else if (e.key === 'ArrowLeft') lbPrev();
else if (e.key === 'ArrowRight') lbNext();
}
async function submitPassword(e: SubmitEvent) {
@@ -80,14 +230,28 @@
}
}
onMount(loadMeta);
onMount(() => {
try {
const saved = localStorage.getItem(VIEW_KEY);
if (saved === 'list' || saved === 'grid') viewMode = saved;
} catch {
/* ignore */
}
void loadMeta();
});
</script>
<svelte:head><title>{meta?.item_name ?? t('share.title', 'Shared')} · OxiCloud</title></svelte:head>
<svelte:window onkeydown={onKeydown} onpopstate={onPopState} />
<main class="share">
{#if view === 'loading'}
<p class="share__status">{t('common.loading', 'Loading…')}</p>
{:else if view === 'invalid'}
<div class="share__center">
<Icon name="ban" class="share__big-icon" />
<p>{t('share.invalid', 'This share link is invalid.')}</p>
</div>
{:else if view === 'expired'}
<div class="share__center">
<Icon name="ban" class="share__big-icon" />
@@ -116,14 +280,35 @@
</div>
{:else if view === 'folder' && listing}
<header class="share__header">
<h1>{folderName}</h1>
<nav class="breadcrumb" aria-label={t('files.breadcrumb', 'Breadcrumb')}>
{#each crumbs as c, i (i)}
{#if i > 0}<Icon name="chevron-right" class="breadcrumb__sep" />{/if}
{#if i === crumbs.length - 1}
<span class="breadcrumb__current">{c.name}</span>
{:else}
<button class="breadcrumb__link" onclick={() => gotoCrumb(i)}>{c.name}</button>
{/if}
{/each}
</nav>
<div class="share__header-actions">
{#if folderId}
<button class="link-btn" onclick={() => openFolder(undefined, meta?.item_name ?? '')}>
← {t('share.back_to_root', 'Back to share root')}
</button>
{/if}
<div class="view-toggle" role="group" aria-label={t('files.view', 'View')}>
<button
type="button"
aria-pressed={viewMode === 'grid'}
class:active={viewMode === 'grid'}
title={t('files.grid', 'Grid')}
onclick={() => setViewMode('grid')}><Icon name="th" /></button
>
<button
type="button"
aria-pressed={viewMode === 'list'}
class:active={viewMode === 'list'}
title={t('files.list', 'List')}
onclick={() => setViewMode('list')}><Icon name="bars" /></button
>
</div>
<a class="share__btn" href={shareZipUrl(token, folderId)} download>
<Icon name="file-archive" />
{t('share.download_zip', 'Download ZIP')}
</a>
</div>
@@ -135,11 +320,11 @@
{#if listing.folders.length > 0}
<h2 class="share__section">{t('share.folders', 'Folders')}</h2>
<ul class="share__grid">
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
{#each listing.folders as f (f.id)}
<li>
<button class="card" onclick={() => openFolder(f.id, f.name)}>
<Icon name="folder" class="card__icon" />
<button class="card" onclick={() => openFolder(f.id, { id: f.id, name: f.name }, true)}>
<span class="card__thumb"><Icon name="folder" class="card__icon" /></span>
<span class="card__name">{f.name}</span>
</button>
</li>
@@ -149,20 +334,94 @@
{#if listing.files.length > 0}
<h2 class="share__section">{t('share.files', 'Files')}</h2>
<ul class="share__grid">
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
{#each listing.files as f (f.id)}
<li>
<a class="card" href={shareFileUrl(token, f.id)} target="_blank" rel="noreferrer">
<Icon name="file" class="card__icon" />
<span class="card__name">{f.name}</span>
</a>
</li>
{@const kind = mediaKind(f.mime_type)}
{#if kind}
<li>
<button
class="card"
onclick={() => (lightbox = mediaFiles.findIndex((m) => m.id === f.id))}
>
<span class="card__thumb">
{#if kind === 'image'}
<img
src={shareFileUrl(token, f.id)}
alt={f.name}
loading="lazy"
decoding="async"
use:imageRetry
/>
{:else}
<video
use:lazyVideo={shareFileUrl(token, f.id)}
preload="metadata"
muted
playsinline
></video>
<span class="card__play"><Icon name="play" /></span>
{/if}
</span>
<span class="card__name">{f.name}</span>
</button>
</li>
{:else}
<li>
<a class="card" href={shareFileUrl(token, f.id)} target="_blank" rel="noreferrer">
<span class="card__thumb"><Icon name="file" class="card__icon" /></span>
<span class="card__name">{f.name}</span>
</a>
</li>
{/if}
{/each}
</ul>
{/if}
{/if}
</main>
{#if lightbox >= 0 && mediaFiles[lightbox]}
{@const m = mediaFiles[lightbox]}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="lb"
role="dialog"
aria-modal="true"
aria-label={m.name}
tabindex="-1"
onclick={(e) => e.target === e.currentTarget && (lightbox = -1)}
>
<button
class="lb__close"
aria-label={t('common.close', 'Close')}
onclick={() => (lightbox = -1)}>×</button
>
<button
class="lb__nav lb__nav--prev"
aria-label={t('common.previous', 'Previous')}
disabled={lightbox === 0}
onclick={(e) => {
e.stopPropagation();
lbPrev();
}}><Icon name="chevron-left" /></button
>
{#if mediaKind(m.mime_type) === 'image'}
<img class="lb__media" src={shareFileUrl(token, m.id)} alt={m.name} />
{:else}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="lb__media" src={shareFileUrl(token, m.id)} controls autoplay></video>
{/if}
<button
class="lb__nav lb__nav--next"
aria-label={t('common.next', 'Next')}
disabled={lightbox === mediaFiles.length - 1}
onclick={(e) => {
e.stopPropagation();
lbNext();
}}><Icon name="chevron-right" /></button
>
</div>
{/if}
<style>
.share {
max-width: 60rem;
@@ -248,7 +507,7 @@
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 1rem 0.5rem;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
@@ -261,6 +520,35 @@
background: var(--color-bg-hover);
}
.card__thumb {
position: relative;
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--color-bg-muted);
}
.card__thumb img,
.card__thumb video {
width: 100%;
height: 100%;
object-fit: cover;
}
.card__play {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--color-on-accent);
font-size: 1.5rem;
background: var(--color-scrim-control, transparent);
opacity: 0.85;
}
:global(.card__icon) {
font-size: 2rem;
color: var(--color-text-muted);
@@ -275,7 +563,36 @@
max-width: 100%;
}
/* List mode: cards become single-row rows with a small leading thumb. */
.share__grid--list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.share__grid--list .card {
flex-direction: row;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.6rem;
}
.share__grid--list .card__thumb {
width: 2.5rem;
height: 2.5rem;
aspect-ratio: auto;
flex-shrink: 0;
}
.share__grid--list .card__name {
text-align: left;
flex: 1;
}
.share__btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-md);
@@ -285,10 +602,101 @@
cursor: pointer;
}
.link-btn {
.breadcrumb {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.breadcrumb__link {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 1rem;
padding: 0;
}
.breadcrumb__current {
font-weight: var(--weight-semibold, 600);
}
:global(.breadcrumb__sep) {
color: var(--color-text-muted);
font-size: 0.75rem;
}
.view-toggle {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.view-toggle button {
padding: 0.4rem 0.6rem;
border: none;
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.view-toggle button.active {
background: var(--color-accent);
color: var(--color-on-accent);
}
.lb {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--color-lightbox-overlay);
display: flex;
align-items: center;
justify-content: center;
}
.lb__media {
max-width: 92vw;
max-height: 88vh;
object-fit: contain;
}
.lb__close {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 2rem;
line-height: 1;
background: none;
border: none;
color: var(--color-on-accent);
cursor: pointer;
}
.lb__nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 2rem;
background: none;
border: none;
color: var(--color-on-accent);
cursor: pointer;
padding: 1rem;
}
.lb__nav:disabled {
opacity: 0.3;
cursor: default;
}
.lb__nav--prev {
left: 0.5rem;
}
.lb__nav--next {
right: 0.5rem;
}
</style>
+398
View File
@@ -0,0 +1,398 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { searchFiles } from '$lib/api/endpoints/search';
import { fileInlineUrl } from '$lib/api/endpoints/files';
import type { FileItem, FolderItem, SearchResults, SortBy } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
import { formatBytes } from '$lib/utils/format';
import { formatDate, iconNameFromClass } from '$lib/utils/display';
const query = $derived(page.url.searchParams.get('q') ?? '');
let results = $state<SearchResults | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let sortBy = $state<SortBy>('relevance');
// Scope: search everywhere, or within the folder last open in the files view.
// Default to the current folder when one is set (and we're not in the trash
// section), mirroring the legacy searchView behaviour.
let scope = $state<'all' | 'folder'>(
filesStore.currentFolder && filesStore.section !== 'trash' ? 'folder' : 'all'
);
// Filters
type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive';
type SizeKey = 'all' | 'small' | 'medium' | 'large';
type DateKey = 'all' | 'day' | 'week' | 'month' | 'year';
let typeFilter = $state<TypeKey>('all');
let sizeFilter = $state<SizeKey>('all');
let dateFilter = $state<DateKey>('all');
const TYPE_EXT: Record<Exclude<TypeKey, 'all'>, string[]> = {
image: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
video: ['mp4', 'mov', 'mkv', 'avi', 'webm', 'm4v', 'wmv', 'flv'],
document: [
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'md',
'odt',
'rtf',
'csv'
],
audio: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'opus'],
archive: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz']
};
const TYPES: { v: TypeKey; l: string }[] = [
{ v: 'all', l: t('search.type.all', 'All types') },
{ v: 'image', l: t('search.type.image', 'Images') },
{ v: 'video', l: t('search.type.video', 'Videos') },
{ v: 'document', l: t('search.type.document', 'Documents') },
{ v: 'audio', l: t('search.type.audio', 'Audio') },
{ v: 'archive', l: t('search.type.archive', 'Archives') }
];
const SIZES: { v: SizeKey; l: string }[] = [
{ v: 'all', l: t('search.size.all', 'Any size') },
{ v: 'small', l: t('search.size.small', '< 1 MB') },
{ v: 'medium', l: t('search.size.medium', '1–100 MB') },
{ v: 'large', l: t('search.size.large', '> 100 MB') }
];
const DATES: { v: DateKey; l: string }[] = [
{ v: 'all', l: t('search.date.all', 'Any time') },
{ v: 'day', l: t('search.date.day', 'Past 24 hours') },
{ v: 'week', l: t('search.date.week', 'Past week') },
{ v: 'month', l: t('search.date.month', 'Past month') },
{ v: 'year', l: t('search.date.year', 'Past year') }
];
const MB = 1024 * 1024;
function sizeBounds(k: SizeKey): { minSize?: number; maxSize?: number } {
switch (k) {
case 'small':
return { maxSize: MB };
case 'medium':
return { minSize: MB, maxSize: 100 * MB };
case 'large':
return { minSize: 100 * MB };
default:
return {};
}
}
function dateBound(k: DateKey): number | undefined {
const day = 86400;
const now = Math.floor(Date.now() / 1000);
switch (k) {
case 'day':
return now - day;
case 'week':
return now - 7 * day;
case 'month':
return now - 30 * day;
case 'year':
return now - 365 * day;
default:
return undefined;
}
}
const hasFilters = $derived(typeFilter !== 'all' || sizeFilter !== 'all' || dateFilter !== 'all');
function clearFilters() {
typeFilter = 'all';
sizeFilter = 'all';
dateFilter = 'all';
}
const SORTS: { v: SortBy; l: string }[] = [
{ v: 'relevance', l: t('search.sort.relevance', 'Relevance') },
{ v: 'name', l: t('search.sort.name_asc', 'Name A-Z') },
{ v: 'name_desc', l: t('search.sort.name_desc', 'Name Z-A') },
{ v: 'date_desc', l: t('search.sort.newest', 'Newest') },
{ v: 'date', l: t('search.sort.oldest', 'Oldest') },
{ v: 'size_desc', l: t('search.sort.largest', 'Largest') },
{ v: 'size', l: t('search.sort.smallest', 'Smallest') }
];
async function run(q: string) {
if (!q) {
results = null;
return;
}
loading = true;
error = null;
try {
// Trash section searches are always global — there is no folder to scope to.
const folderId =
scope === 'folder' && filesStore.section !== 'trash'
? (filesStore.currentFolder ?? undefined)
: undefined;
results = await searchFiles(q, {
recursive: true,
sortBy,
folderId,
fileTypes: typeFilter === 'all' ? undefined : TYPE_EXT[typeFilter],
...sizeBounds(sizeFilter),
modifiedAfter: dateBound(dateFilter)
});
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
function openFolder(folder: FolderItem) {
goto(`/files/${folder.id}`);
}
function openFile(file: FileItem) {
window.open(fileInlineUrl(file.id), '_blank', 'noopener');
}
const isEmpty = $derived(!!results && results.files.length === 0 && results.folders.length === 0);
$effect(() => {
// re-run when query, sort, scope, or any filter changes
void sortBy;
void scope;
void typeFilter;
void sizeFilter;
void dateFilter;
void run(query);
});
</script>
<svelte:head><title>{t('search.title', 'Search')} · OxiCloud</title></svelte:head>
<div class="page-sticky-header search-head">
<h1 class="page-title">
{#if query}{t('search.results_for', { q: query }, 'Results for “{{q}}”')}{:else}{t(
'search.title',
'Search'
)}{/if}
{#if results?.query_time_ms != null}
<span class="search-time">({results.query_time_ms} ms)</span>
{/if}
</h1>
{#if query}
<div class="search-controls">
{#if filesStore.currentFolder}
<div class="seg" role="group" aria-label={t('search.scope', 'Scope')}>
<button class="seg__btn" class:active={scope === 'all'} onclick={() => (scope = 'all')}>
{t('search.everywhere', 'Everywhere')}
</button>
<button
class="seg__btn"
class:active={scope === 'folder'}
onclick={() => (scope = 'folder')}
>
{t('search.this_folder', 'This folder')}
</button>
</div>
{/if}
<select
class="sort-select"
bind:value={typeFilter}
aria-label={t('search.type_label', 'Type')}
>
{#each TYPES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
</select>
<select
class="sort-select"
bind:value={sizeFilter}
aria-label={t('search.size_label', 'Size')}
>
{#each SIZES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
</select>
<select
class="sort-select"
bind:value={dateFilter}
aria-label={t('search.date_label', 'Date')}
>
{#each DATES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
</select>
<select class="sort-select" bind:value={sortBy} aria-label={t('search.sort_by', 'Sort by')}>
{#each SORTS as s (s.v)}<option value={s.v}>{s.l}</option>{/each}
</select>
{#if hasFilters}
<button class="clear-filters" onclick={clearFilters}>
<Icon name="times" />
{t('search.clear_filters', 'Clear filters')}
</button>
{/if}
</div>
{/if}
</div>
{#if loading}
<div class="search-loading">
<Icon name="spinner" class="search-loading__spinner" />
<h2 class="search-loading__text">
{t('search.searching_for', { q: query }, 'Searching for “{{q}}”…')}
</h2>
</div>
{:else if error}
<div class="empty-state"><p>{error}</p></div>
{:else if !query}
<div class="empty-state">
<p>{t('search.prompt', 'Type a query in the search bar above.')}</p>
</div>
{:else if isEmpty}
<div class="empty-state search-empty">
<Icon name="search" />
<p>{t('search.no_results', 'No results found for this search')}</p>
</div>
{:else if results}
<div class="files-container">
<div class="files-list-view" style="--files-list-columns: minmax(200px, 2fr) 1fr 110px 140px">
<div class="list-header">
<div>{t('files.col_name', 'Name')}</div>
<div>{t('files.col_path', 'Path')}</div>
<div>{t('files.col_size', 'Size')}</div>
<div>{t('files.col_modified', 'Modified')}</div>
</div>
{#each results.folders as folder (folder.id)}
<div
class="file-item"
role="button"
tabindex="0"
onclick={() => openFolder(folder)}
onkeydown={(e) => e.key === 'Enter' && openFolder(folder)}
>
<div class="name-cell">
<span class="file-icon"><Icon name="folder" /></span>
<span>{folder.name}</span>
</div>
<div class="path-cell">{folder.path}</div>
<div class="size-cell">—</div>
<div class="date-cell">{formatDate(folder.modified_at)}</div>
</div>
{/each}
{#each results.files as file (file.id)}
<div
class="file-item"
role="button"
tabindex="0"
onclick={() => openFile(file)}
onkeydown={(e) => e.key === 'Enter' && openFile(file)}
>
<div class="name-cell">
<span class="file-icon"><Icon name={iconNameFromClass(file.icon_class)} /></span>
<span>{file.name}</span>
</div>
<div class="path-cell">{file.path}</div>
<div class="size-cell">{file.size != null ? formatBytes(file.size) : ''}</div>
<div class="date-cell">{formatDate(file.modified_at)}</div>
</div>
{/each}
</div>
</div>
{/if}
<style>
.search-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.search-controls {
display: flex;
align-items: center;
gap: var(--space-2);
}
.sort-select {
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.seg {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.seg__btn {
padding: var(--space-2) var(--space-3);
border: none;
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.seg__btn.active {
background: var(--color-accent);
color: var(--color-on-accent);
}
.search-time {
font-size: var(--text-sm);
font-weight: var(--weight-normal);
color: var(--color-text-muted);
}
.clear-filters {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.clear-filters:hover {
background: var(--color-bg-hover);
}
.search-loading {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) 0;
color: var(--color-text-muted);
}
.search-loading :global(.search-loading__spinner) {
font-size: var(--text-xl);
color: var(--color-accent);
animation: spin var(--spin-duration) linear infinite;
}
.search-loading__text {
margin: 0;
font-size: var(--text-lg);
font-weight: var(--weight-medium);
color: var(--color-text);
}
.search-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
}
.search-empty :global(.oxi-icon) {
font-size: var(--text-3xl);
color: var(--color-text-faint);
}
</style>
+45 -29
View File
@@ -1,22 +1,41 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
import type { FileItem } from '$lib/api/types';
import FileViewer from '$lib/components/FileViewer.svelte';
import ResourceList, { type ResourceEntry } from '$lib/components/ResourceList.svelte';
import { t } from '$lib/i18n/index.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<IncomingGrantItem[]>([]);
let raw = $state<IncomingGrantItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
const entries = $derived(
raw.map(
(it): ResourceEntry => ({
id: it.resource.id,
name: it.resource.name,
kind: it.resource_type,
iconClass: it.resource.icon_class,
path: it.granted_by
? t('shared_with_me.from', { who: it.granted_by }, 'Shared by {{who}}')
: it.resource.path,
size: it.resource_type === 'file' ? (it.resource as FileItem).size : null,
date: it.granted_at
})
)
);
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchSharedWithMe({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
raw = reset ? page.items : [...raw, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
@@ -25,38 +44,35 @@
}
}
let viewerOpen = $state(false);
let viewerFile = $state<FileItem | null>(null);
function open(entry: ResourceEntry) {
if (entry.kind === 'folder') {
goto(`/files/${entry.id}`);
return;
}
const item = byId.get(entry.id);
if (item) {
viewerFile = item.resource as FileItem;
viewerOpen = true;
}
}
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.shared_with_me', 'Shared with me')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.shared_with_me', 'Shared with me')}</h1>
<ResourceListShell
<ResourceList
title={t('nav.shared_with_me', 'Shared with me')}
items={entries}
{loading}
{error}
empty={items.length === 0}
emptyText={t('shared_with_me.empty', 'Nothing has been shared with you yet.')}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.granted_by
? t('shared_with_me.from', { who: item.granted_by }, 'Shared by {{who}}')
: item.resource.path}
date={formatDate(item.granted_at)}
/>
{/each}
</ResourceListShell>
onopen={open}
/>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
</style>
<FileViewer bind:open={viewerOpen} file={viewerFile} />
+819 -31
View File
@@ -1,22 +1,155 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import { fetchMyShares, type OutgoingGrantItem } from '$lib/api/endpoints/grants';
import {
expiryToIso,
fetchMyShares,
notifyGrantRecipient,
revokeGrant,
updateGrantRole,
type NotifyOutcome,
type OutgoingGrantItem,
type OutgoingResourceGrant,
type ShareRole
} from '$lib/api/endpoints/grants';
import { copyShareLink, deleteShare, getShareById, updateShare } from '$lib/api/endpoints/shares';
import { ensureResolvers, resolveLabel } from '$lib/api/endpoints/recipients';
import { fileInlineUrl } from '$lib/api/endpoints/files';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import ListToolbar from '$lib/components/ListToolbar.svelte';
import ShareDialog from '$lib/components/ShareDialog.svelte';
import { t } from '$lib/i18n/index.svelte';
import { formatDate } from '$lib/utils/display';
import { ui } from '$lib/stores/ui.svelte';
import { iconNameFromClass } from '$lib/utils/display';
let items = $state<OutgoingGrantItem[]>([]);
type GroupBy = 'items' | 'sharedWith';
const GROUP_BYS: { key: GroupBy; label: string; orderBy: string }[] = [
{ key: 'items', label: t('groupby.byFiles', 'By files'), orderBy: 'type' },
{ key: 'sharedWith', label: t('groupby.sharedWith', 'Shared with'), orderBy: 'subject' }
];
const ROLES: { v: ShareRole; l: string; icon: string }[] = [
{ v: 'admin', l: t('share.role.canManage', 'Can manage'), icon: 'crown' },
{ v: 'editor', l: t('share.role.canEdit', 'Can edit'), icon: 'pencil-alt' },
{ v: 'viewer', l: t('share.role.canView', 'Can view'), icon: 'eye' }
];
function roleMeta(r: string) {
return ROLES.find((x) => x.v === r) ?? ROLES[2];
}
let raw = $state<OutgoingGrantItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
let groupBy = $state<GroupBy>('items');
let reversed = $state(false);
// Edit-sharing dialog
let dialogOpen = $state(false);
let dialogItem = $state<{ id: string; name: string; kind: ItemType } | null>(null);
// Open kebab menu, keyed by grant id.
let menuFor = $state<string | null>(null);
function expiryTier(iso: string | null | undefined): 'never' | 'active' | 'soon' | 'expired' {
if (!iso) return 'never';
const ms = new Date(iso).getTime() - Date.now();
if (ms < 0) return 'expired';
if (ms <= 30 * 86_400_000) return 'soon';
return 'active';
}
function expiryLabel(iso: string | null | undefined): string {
if (!iso) return t('share.noExpiry', 'No expiry');
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function isoToDate(iso: string | null | undefined): string {
return iso ? String(iso).slice(0, 10) : '';
}
// ── Swimlane assembly ───────────────────────────────────────────────────
interface Lane {
key: string;
header:
| { kind: 'resource'; item: OutgoingGrantItem }
| { kind: 'user'; id: string }
| { kind: 'group'; id: string }
| { kind: 'linkPublic' }
| { kind: 'linkPassword' };
rows: { grant: OutgoingResourceGrant; item: OutgoingGrantItem }[];
}
const lanes = $derived.by((): Lane[] => {
const out: Lane[] = [];
const byKey = new Map<string, Lane>();
const ensure = (key: string, header: Lane['header']): Lane => {
let lane = byKey.get(key);
if (!lane) {
lane = { key, header, rows: [] };
byKey.set(key, lane);
out.push(lane);
}
return lane;
};
for (const item of raw) {
if (groupBy === 'items') {
const lane = ensure(`resource:${item.resource.id}`, { kind: 'resource', item });
for (const grant of item.grants) lane.rows.push({ grant, item });
} else {
for (const grant of item.grants) {
let key: string;
let header: Lane['header'];
if (grant.subject_type === 'user') {
key = `user:${grant.subject_id}`;
header = { kind: 'user', id: grant.subject_id };
} else if (grant.subject_type === 'group') {
key = `group:${grant.subject_id}`;
header = { kind: 'group', id: grant.subject_id };
} else if (grant.has_password) {
key = 'links:password';
header = { kind: 'linkPassword' };
} else {
key = 'links:public';
header = { kind: 'linkPublic' };
}
ensure(key, header).rows.push({ grant, item });
}
}
}
return out;
});
function laneTitle(header: Lane['header']): string {
switch (header.kind) {
case 'user':
return resolveLabel('user', header.id);
case 'group':
return resolveLabel('group', header.id);
case 'linkPublic':
return t('myshares.publicLinks', 'Public links');
case 'linkPassword':
return t('myshares.passwordLinks', 'Password-protected links');
case 'resource':
return header.item.resource.name;
}
}
// ── Data loading ────────────────────────────────────────────────────────
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchMyShares({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
await ensureResolvers();
const order = GROUP_BYS.find((g) => g.key === groupBy)?.orderBy;
const page = await fetchMyShares({
cursor: reset ? undefined : cursor,
orderBy: order,
reverse: reversed
});
raw = reset ? page.items : [...raw, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
@@ -25,38 +158,693 @@
}
}
function reload() {
cursor = undefined;
raw = [];
void load(true);
}
function setGroupBy(key: GroupBy) {
if (groupBy === key) return;
groupBy = key;
reload();
}
function toggleDirection() {
reversed = !reversed;
reload();
}
function openResource(item: OutgoingGrantItem) {
if (item.resource_type === 'folder') goto(`/files/${item.resource.id}`);
else window.open(fileInlineUrl(item.resource.id), '_blank', 'noopener');
}
function editSharing(item: OutgoingGrantItem) {
dialogItem = { id: item.resource.id, name: item.resource.name, kind: item.resource_type };
dialogOpen = true;
}
function toggleMenu(grantId: string) {
menuFor = menuFor === grantId ? null : grantId;
}
function closeMenu() {
menuFor = null;
}
function summarize(outcomes: NotifyOutcome[]) {
if (!outcomes || outcomes.length === 0) {
ui.notify(t('myshares.notifySent', 'Notification sent.'), 'success');
return;
}
const sent = outcomes.filter((o) => o.kind === 'sent').length;
const coalesced = outcomes.filter((o) => o.kind === 'coalesced').length;
const rate = outcomes.filter((o) => o.kind === 'rate_limited').length;
const skipped = outcomes.filter((o) => o.kind === 'not_applicable').length;
const lines: string[] = [];
if (sent > 0) lines.push(t('share.notify.sent', { n: sent }, '{{n}} notified by email.'));
if (coalesced > 0)
lines.push(t('share.notify.coalesced', { n: coalesced }, '{{n}} already notified recently.'));
if (rate > 0)
lines.push(
t('share.notify.rateLimited', { n: rate }, '{{n}} hit the rate limit — try later.')
);
if (skipped > 0)
lines.push(
t('share.notify.skipped', { n: skipped }, '{{n}} skipped (no email / opted out).')
);
ui.notify(
lines.join(' ') || t('myshares.notifySent', 'Notification sent.'),
rate || skipped ? 'info' : 'success'
);
}
// ── Per-grant actions ───────────────────────────────────────────────────
async function changeRole(g: OutgoingResourceGrant, item: OutgoingGrantItem, role: ShareRole) {
closeMenu();
if (g.role === role) return;
try {
await updateGrantRole(
{ type: g.subject_type, id: g.subject_id },
{ type: item.resource_type, id: item.resource.id },
role,
expiryToIso(isoToDate(g.expires_at) || null)
);
g.role = role;
raw = [...raw];
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function changeExpiry(g: OutgoingResourceGrant, item: OutgoingGrantItem, date: string) {
try {
const iso = expiryToIso(date || null);
await updateGrantRole(
{ type: g.subject_type, id: g.subject_id },
{ type: item.resource_type, id: item.resource.id },
g.role,
iso
);
g.expires_at = iso;
raw = [...raw];
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function notify(g: OutgoingResourceGrant) {
closeMenu();
try {
summarize((await notifyGrantRecipient(g.grant_id)).outcomes);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function removeAccess(g: OutgoingResourceGrant) {
closeMenu();
try {
await revokeGrant(g.grant_id);
dropGrant(g.grant_id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function copyLink(g: OutgoingResourceGrant) {
closeMenu();
try {
const share = await getShareById(g.subject_id);
if (await copyShareLink(share.url)) ui.notify(t('share.copied', 'Link copied'), 'success');
else ui.notify(t('share.copy_failed', 'Could not copy link'), 'error');
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function changeLinkExpiry(g: OutgoingResourceGrant, date: string) {
try {
await updateShare(g.subject_id, { expiresAt: date || null });
g.expires_at = expiryToIso(date || null);
raw = [...raw];
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function editLinkPassword(g: OutgoingResourceGrant) {
closeMenu();
const pw = window.prompt(
g.has_password
? t('share.passwordPrompt_clear', 'New password (blank to remove):')
: t('share.passwordPrompt', 'Set a password:')
);
if (pw === null) return;
try {
await updateShare(g.subject_id, { password: pw || null });
g.has_password = !!pw;
raw = [...raw];
ui.notify(
pw
? t('share.password_set', 'Password updated')
: t('share.password_cleared', 'Password removed'),
'success'
);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function deleteLink(g: OutgoingResourceGrant) {
closeMenu();
try {
await deleteShare(g.subject_id);
dropGrant(g.grant_id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
/** Remove a grant locally, pruning now-empty resources. */
function dropGrant(grantId: string) {
raw = raw
.map((item) => ({ ...item, grants: item.grants.filter((g) => g.grant_id !== grantId) }))
.filter((item) => item.grants.length > 0);
}
function linkLabel(g: OutgoingResourceGrant): string {
return `${t('share.link', 'Link')} · …${g.subject_id.slice(-4)} · ${g.subject_display}`;
}
function resourceIcon(item: OutgoingGrantItem): string {
return item.resource_type === 'folder'
? 'folder'
: iconNameFromClass((item.resource as FileItem | FolderItem).icon_class);
}
const isEmpty = $derived(!loading && raw.length === 0 && !error);
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.shared', 'Shared')} · OxiCloud</title></svelte:head>
<svelte:window onclick={() => menuFor && closeMenu()} />
<h1 class="page-title">{t('nav.shared', 'Shared')}</h1>
<div class="page-sticky-header">
<h1 class="page-title">{t('nav.shared', 'Shared')}</h1>
<ListToolbar
groups={GROUP_BYS}
{groupBy}
{reversed}
ongroup={(key) => setGroupBy(key as GroupBy)}
ondirection={toggleDirection}
showViewToggle={false}
/>
</div>
<ResourceListShell
{loading}
{error}
empty={items.length === 0}
emptyText={t('shared.empty', "You haven't shared anything yet.")}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.subject
? t('shared.with', { who: item.subject }, 'Shared with {{who}}')
: item.resource.path}
date={formatDate(item.first_shared_at)}
/>
{/each}
</ResourceListShell>
{#if error}
<div class="empty-state">
<Icon name="exclamation-circle" class="empty-state-icon empty-state-icon--error" />
<p>{error}</p>
</div>
{:else if isEmpty}
<div class="empty-state">
<Icon name="share-alt" class="empty-state-icon" />
<p>{t('myshares.emptyStateTitle', "You haven't shared anything yet")}</p>
<p class="empty-state__hint">
{t('myshares.emptyStateDesc', 'Items you share with others will appear here')}
</p>
</div>
{:else}
<div class="ms-lanes">
{#each lanes as lane (lane.key)}
<section class="ms-lane">
<header class="ms-lane__header">
{#if lane.header.kind === 'resource'}
{@const laneItem = lane.header.item}
<button class="ms-lane__resource" onclick={() => openResource(laneItem)}>
<Icon name={resourceIcon(laneItem)} />
<span class="ms-lane__name">{laneItem.resource.name}</span>
</button>
<button class="btn btn-secondary ms-lane__edit" onclick={() => editSharing(laneItem)}>
<Icon name="pencil-alt" />
{t('myshares.editSharing', 'Edit sharing')}
</button>
{:else}
<span class="ms-lane__subject">
<Icon
name={lane.header.kind === 'user'
? 'user'
: lane.header.kind === 'group'
? 'user-group'
: lane.header.kind === 'linkPassword'
? 'lock'
: 'link'}
/>
<span class="ms-lane__name">{laneTitle(lane.header)}</span>
</span>
{/if}
</header>
<ul class="ms-rows">
{#each lane.rows as { grant, item } (grant.grant_id)}
{@const tier = expiryTier(grant.expires_at)}
<li class="ms-row" class:ms-row--expired={tier === 'expired'}>
<!-- Identity -->
<span class="ms-row__identity">
{#if (grant.subject_type === 'user' || grant.subject_type === 'group') && groupBy === 'sharedWith'}
<button class="ms-link-btn" onclick={() => openResource(item)}>
<Icon name={resourceIcon(item)} />
<span class="ms-row__name">{item.resource.name}</span>
</button>
{:else if grant.subject_type === 'user'}
<Icon name="user" />
<span class="ms-row__name">{resolveLabel('user', grant.subject_id)}</span>
{:else if grant.subject_type === 'group'}
<Icon name="user-group" />
<span class="ms-row__name">{resolveLabel('group', grant.subject_id)}</span>
{:else}
<button
class="ms-chip ms-chip--link"
class:ms-chip--locked={grant.has_password}
onclick={() => copyLink(grant)}
title={t('share.copyLink', 'Copy link')}
>
<Icon name={grant.has_password ? 'lock' : 'link'} />
<span class="ms-row__name">{linkLabel(grant)}</span>
</button>
{#if groupBy === 'sharedWith'}
<span class="ms-arrow">→</span>
<button class="ms-link-btn" onclick={() => openResource(item)}>
<Icon name={resourceIcon(item)} />
<span>{item.resource.name}</span>
</button>
{/if}
{/if}
</span>
<!-- Role pill (not shown for token subjects) -->
{#if grant.subject_type !== 'token'}
<span class="ms-role ms-role--{roleMeta(grant.role).v}">
<Icon name={roleMeta(grant.role).icon} />
{roleMeta(grant.role).l}
</span>
{/if}
<!-- Expiry chip -->
<span class="ms-expiry ms-expiry--{tier}" title={expiryLabel(grant.expires_at)}>
<Icon name={grant.expires_at ? 'clock' : 'infinity'} />
{expiryLabel(grant.expires_at)}
</span>
<!-- Kebab -->
<div class="ms-kebab">
<button
class="btn-icon"
aria-label={t('myshares.manageAccess', 'Manage access')}
aria-haspopup="menu"
aria-expanded={menuFor === grant.grant_id}
onclick={(e) => {
e.stopPropagation();
toggleMenu(grant.grant_id);
}}><Icon name="ellipsis-v" /></button
>
{#if menuFor === grant.grant_id}
<div
class="ms-menu"
role="menu"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.key === 'Escape' && closeMenu()}
>
{#if grant.subject_type === 'user' || grant.subject_type === 'group'}
<button class="ms-menu__item" role="menuitem" onclick={() => notify(grant)}>
<Icon name="paper-plane" />
{grant.subject_type === 'group'
? t('myshares.notifyGroupMembers', 'Notify group members')
: grant.is_external
? t('myshares.resendInvitation', 'Resend invitation email')
: t('myshares.notifyByEmail', 'Notify by email')}
</button>
<div class="ms-menu__sep"></div>
{#each ROLES as r (r.v)}
<button
class="ms-menu__item"
class:ms-menu__item--current={grant.role === r.v}
role="menuitem"
onclick={() => changeRole(grant, item, r.v)}
>
<Icon name={grant.role === r.v ? 'check' : r.icon} />
{r.l}
</button>
{/each}
<div class="ms-menu__sep"></div>
<div class="ms-menu__field">
<span class="ms-menu__label">{t('share.expiry', 'Expiry')}</span>
<input
type="date"
class="ms-menu__date"
value={isoToDate(grant.expires_at)}
onchange={(e) =>
changeExpiry(grant, item, (e.currentTarget as HTMLInputElement).value)}
/>
</div>
<div class="ms-menu__sep"></div>
<button
class="ms-menu__item ms-menu__item--danger"
role="menuitem"
onclick={() => removeAccess(grant)}
>
<Icon name="user-xmark" />
{t('myshares.removeAccess', 'Remove access')}
</button>
{:else}
<button class="ms-menu__item" role="menuitem" onclick={() => copyLink(grant)}>
<Icon name="copy" />
{t('myshares.copyLink', 'Copy link')}
</button>
<div class="ms-menu__sep"></div>
<div class="ms-menu__field">
<span class="ms-menu__label">{t('share.expiry', 'Expiry')}</span>
<input
type="date"
class="ms-menu__date"
value={isoToDate(grant.expires_at)}
onchange={(e) =>
changeLinkExpiry(grant, (e.currentTarget as HTMLInputElement).value)}
/>
</div>
<button
class="ms-menu__item"
role="menuitem"
onclick={() => editLinkPassword(grant)}
>
<Icon name={grant.has_password ? 'lock' : 'lock-open'} />
{grant.has_password
? t('share.changePassword', 'Change password')
: t('share.addPassword', 'Add password')}
</button>
<div class="ms-menu__sep"></div>
<button
class="ms-menu__item ms-menu__item--danger"
role="menuitem"
onclick={() => deleteLink(grant)}
>
<Icon name="trash" />
{t('myshares.deleteLink', 'Delete link')}
</button>
{/if}
</div>
{/if}
</div>
</li>
{/each}
</ul>
</section>
{/each}
{#if cursor}
<button class="btn btn-secondary ms-more" onclick={() => load(false)} disabled={loading}>
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
</button>
{/if}
</div>
{/if}
<ShareDialog bind:open={dialogOpen} item={dialogItem} />
<style>
.page-title {
.ms-lanes {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-top: var(--space-3);
}
.ms-lane {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: visible;
}
.ms-lane__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border-faint, var(--color-border));
background: var(--color-bg-muted);
}
.ms-lane__resource,
.ms-lane__subject {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-weight: var(--weight-semibold, 600);
color: var(--color-text);
background: none;
border: none;
cursor: pointer;
min-width: 0;
}
.ms-lane__subject {
cursor: default;
}
.ms-lane__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-lane__edit {
display: inline-flex;
align-items: center;
gap: var(--space-1);
flex: none;
}
.ms-rows {
list-style: none;
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
padding: 0;
display: flex;
flex-direction: column;
}
.ms-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--color-border-faint, var(--color-border));
}
.ms-row:first-child {
border-top: none;
}
.ms-row--expired {
opacity: 0.6;
}
.ms-row__identity {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
min-width: 0;
}
.ms-row__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-link-btn,
.ms-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
min-width: 0;
padding: 0;
}
.ms-chip--link {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill, 999px);
}
.ms-chip--locked {
color: var(--color-accent);
}
.ms-arrow {
color: var(--color-text-muted);
}
/* Role pill */
.ms-role {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-pill, 999px);
font-size: var(--text-sm);
background: var(--color-bg-muted);
color: var(--color-text-secondary);
flex: none;
}
.ms-role--admin {
background: var(--color-warning-bg, var(--color-bg-muted));
color: var(--color-warning-text-amber, var(--color-text-secondary));
}
.ms-role--editor {
background: var(--color-accent-bg, var(--color-bg-muted));
color: var(--color-accent-text, var(--color-text-secondary));
}
/* Expiry chip tiers */
.ms-expiry {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-pill, 999px);
font-size: var(--text-sm);
color: var(--color-text-muted);
background: var(--color-bg-muted);
flex: none;
}
.ms-expiry--soon {
color: var(--color-warning-text-amber, var(--color-text-secondary));
background: var(--color-warning-bg, var(--color-bg-muted));
}
.ms-expiry--expired {
color: var(--color-danger-text);
background: var(--color-danger-bg, var(--color-bg-muted));
}
/* Kebab + menu */
.ms-kebab {
position: relative;
flex: none;
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
}
.btn-icon:hover {
background: var(--color-bg-hover);
}
.ms-menu {
position: absolute;
right: 0;
top: 100%;
z-index: 50;
min-width: 14rem;
margin-top: var(--space-1);
padding: var(--space-1);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.ms-menu__item {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
text-align: left;
cursor: pointer;
}
.ms-menu__item:hover {
background: var(--color-bg-hover);
}
.ms-menu__item--current {
font-weight: var(--weight-semibold, 600);
}
.ms-menu__item--danger {
color: var(--color-danger-text);
}
.ms-menu__sep {
height: 1px;
margin: var(--space-1) 0;
background: var(--color-border);
}
.ms-menu__field {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
}
.ms-menu__label {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.ms-menu__date {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
font-size: var(--text-sm);
}
.ms-more {
margin: var(--space-3) auto 0;
}
.empty-state__hint {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.empty-state :global(.empty-state-icon) {
font-size: var(--text-5xl);
color: var(--color-text-faint);
margin-bottom: var(--space-2);
}
.empty-state :global(.empty-state-icon--error) {
color: var(--color-danger-text);
}
</style>
+176 -56
View File
@@ -1,62 +1,146 @@
<script lang="ts">
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import {
deleteTrashItem,
emptyTrash,
expiryChip,
fetchTrashPage,
remainingDaysBucket,
restoreTrashItem
} from '$lib/api/endpoints/trash';
import type { TrashResourceItem } from '$lib/api/types';
import { dateBucket, sizeBucket, typeLabel } from '$lib/api/endpoints/favorites';
import type { FileItem, TrashResourceItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import ResourceList, {
type GroupByDef,
type ResourceEntry
} from '$lib/components/ResourceList.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<TrashResourceItem[]>([]);
let raw = $state<TrashResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
// Default: items expiring soonest first, grouped by remaining days.
let groupBy = $state('remainingDays');
let reversed = $state(false);
async function load(reset = false) {
const entries = $derived(
raw.map((it): ResourceEntry => {
const isFile = it.resource_type === 'file';
return {
id: it.resource.id,
name: it.resource.name,
kind: it.resource_type,
iconClass: it.resource.icon_class,
path: it.resource.path,
size: isFile ? (it.resource as FileItem).size : null,
// `date` carries the deletion date — rendered as an expiry chip.
date: it.deletion_date,
category: isFile ? it.resource.category : 'Folder',
modifiedAt: it.trashed_at
};
})
);
const groupBys: GroupByDef[] = [
{ key: '', label: t('files.name', 'Name'), orderBy: 'name' },
{
key: 'remainingDays',
label: t('trash.groupby.remaining_days', 'Remaining days'),
orderBy: 'deletion_date',
bucketOf: (e) => remainingDaysBucket(e.date)
},
{
key: 'type',
label: t('groupby.type', 'Type'),
orderBy: 'type',
bucketOf: (e) => e.category ?? 'other',
labelOf: (k) => typeLabel(k)
},
{
key: 'size',
label: t('groupby.size', 'Size'),
orderBy: 'size',
bucketOf: (e) => sizeBucket(e.kind === 'folder' ? null : e.size)
},
{
key: 'trashedTime',
label: t('trash.groupby.trashed_time', 'Trashed time'),
orderBy: 'trashed_at',
bucketOf: (e) => dateBucket(e.modifiedAt)
}
];
function orderByForGroup(): string {
return groupBys.find((g) => g.key === groupBy)?.orderBy ?? 'deletion_date';
}
async function load(reset = false, orderBy = 'deletion_date', rev = reversed) {
loading = true;
error = null;
try {
const page = await fetchTrashPage({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
const page = await fetchTrashPage({
cursor: reset ? undefined : cursor,
orderBy,
reverse: rev,
resourceTypes: ['file', 'folder']
});
raw = reset ? page.items : [...raw, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
console.error('trash: load error', e);
error = t('errors_loadFailed', 'Failed to load items');
} finally {
loading = false;
}
}
async function restore(item: TrashResourceItem) {
/** Re-fetch from the top so pagination + grouping stay correct after a mutation. */
async function reloadFromTop() {
cursor = undefined;
await load(true, orderByForGroup());
}
async function restore(entry: ResourceEntry) {
try {
await restoreTrashItem(item.resource.id);
items = items.filter((i) => i.resource.id !== item.resource.id);
await restoreTrashItem(entry.id);
ui.notify(t('trash.restored', 'Restored'), 'success');
await reloadFromTop();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function purge(item: TrashResourceItem) {
if (!confirm(t('trash.confirm_delete', 'Permanently delete this item?'))) return;
async function purge(entry: ResourceEntry) {
const ok = await confirmDialog({
title: t('trash.delete', 'Delete permanently'),
message: t('trash.confirm_delete', 'Permanently delete this item? This cannot be undone.'),
confirmText: t('trash.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
await deleteTrashItem(item.resource.id);
items = items.filter((i) => i.resource.id !== item.resource.id);
await deleteTrashItem(entry.id);
await reloadFromTop();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function purgeAll() {
if (!confirm(t('trash.confirm_empty', 'Empty the trash? This cannot be undone.'))) return;
const ok = await confirmDialog({
title: t('trash.empty_action', 'Empty trash'),
message: t('trash.confirm_empty', 'Empty the trash? This cannot be undone.'),
confirmText: t('trash.empty_action', 'Empty trash'),
danger: true
});
if (!ok) return;
try {
await emptyTrash();
items = [];
raw = [];
cursor = undefined;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
@@ -68,60 +152,96 @@
<svelte:head><title>{t('nav.trash', 'Trash')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.trash', 'Trash')}</h1>
<ResourceListShell
<ResourceList
title={t('nav.trash', 'Trash')}
items={entries}
{loading}
{error}
empty={items.length === 0}
emptyText={t('trash.empty', 'Trash is empty.')}
emptyIcon="trash"
emptyText={t('trash.empty_state', 'Trash is empty')}
hasMore={!!cursor}
onloadmore={() => load(false)}
onloadmore={() => load(false, orderByForGroup())}
pathLabel={t('trash.original_location', 'Original location')}
dateLabel={t('trash.remaining', 'Remaining')}
{groupBys}
bind:groupBy
bind:reversed
onreload={(orderBy, rev) => {
cursor = undefined;
load(true, orderBy, rev);
}}
>
{#snippet toolbar()}
{#if items.length > 0}
<button class="link-btn link-btn--danger" onclick={purgeAll}>
{#if entries.length > 0}
<button class="btn btn-danger" onclick={purgeAll}>
<Icon name="trash" />
{t('trash.empty_action', 'Empty trash')}
</button>
{/if}
{/snippet}
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.resource.path}
date={formatDate(item.deletion_date)}
{#snippet dateCell(entry)}
{@const chip = expiryChip(entry.date)}
<span class="expiry-chip expiry-chip--{chip.tier}">
<Icon name={chip.icon} class="expiry-chip__icon" />
{chip.label}
</span>
{/snippet}
{#snippet actions(entry)}
<button class="btn-action" title={t('trash.restore', 'Restore')} onclick={() => restore(entry)}>
<Icon name="undo" />
</button>
<button
class="btn-action btn-action--delete"
title={t('trash.delete', 'Delete permanently')}
onclick={() => purge(entry)}
>
{#snippet actions()}
<button class="link-btn" onclick={() => restore(item)}>
{t('trash.restore', 'Restore')}
</button>
<button class="link-btn link-btn--danger" onclick={() => purge(item)}>
{t('trash.delete', 'Delete')}
</button>
{/snippet}
</FileRow>
{/each}
</ResourceListShell>
<Icon name="trash" />
</button>
{/snippet}
</ResourceList>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
/* Tiered expiry chip — ported from static/css expiryChip styles. */
.expiry-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-pill, var(--radius-md));
font-size: var(--text-xs);
font-weight: var(--weight-medium, 500);
white-space: nowrap;
background: var(--color-bg-muted);
color: var(--color-text-secondary);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
.expiry-chip :global(.expiry-chip__icon) {
font-size: 0.85em;
}
.link-btn--danger {
.expiry-chip--never {
background: var(--color-bg-muted);
color: var(--color-text-faint);
}
.expiry-chip--caution {
background: var(--color-warning-bg);
color: var(--color-warning-text-amber);
}
.expiry-chip--soon {
background: var(--color-warning-orange-bg);
color: var(--color-warning-text-orange);
}
.expiry-chip--urgent {
background: var(--color-danger-bg);
color: var(--color-danger-text);
}
.expiry-chip--expired {
background: var(--color-danger-bg);
color: var(--color-danger-text);
font-weight: var(--weight-semibold);
}
</style>