visual continunity
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user