Compare commits
13 Commits
e72d4822e9
...
1f591f3a3f
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f591f3a3f | |||
| 1931adcd38 | |||
| 21f3acadf0 | |||
| 871250345a | |||
| 6e24060d99 | |||
| f7d7e8ce37 | |||
| b9cace2997 | |||
| a5b610d472 | |||
| 84c47d0282 | |||
| 6fa340b17c | |||
| aebf7127af | |||
| 63ea1a4d6a | |||
| 27d8215a0a |
@ -93,6 +93,7 @@ func NewRouter(
|
|||||||
|
|
||||||
tags.GET("/:tag_id/rules", tagHandler.ListRules)
|
tags.GET("/:tag_id/rules", tagHandler.ListRules)
|
||||||
tags.POST("/:tag_id/rules", tagHandler.CreateRule)
|
tags.POST("/:tag_id/rules", tagHandler.CreateRule)
|
||||||
|
tags.PATCH("/:tag_id/rules/:then_tag_id", tagHandler.PatchRule)
|
||||||
tags.DELETE("/:tag_id/rules/:then_tag_id", tagHandler.DeleteRule)
|
tags.DELETE("/:tag_id/rules/:then_tag_id", tagHandler.DeleteRule)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -354,6 +354,39 @@ func (h *TagHandler) CreateRule(c *gin.Context) {
|
|||||||
respondJSON(c, http.StatusCreated, toTagRuleJSON(*rule))
|
respondJSON(c, http.StatusCreated, toTagRuleJSON(*rule))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PATCH /tags/:tag_id/rules/:then_tag_id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (h *TagHandler) PatchRule(c *gin.Context) {
|
||||||
|
whenTagID, ok := parseTagID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
thenTagID, err := uuid.Parse(c.Param("then_tag_id"))
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.IsActive == nil {
|
||||||
|
respondError(c, domain.ErrValidation)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rule, err := h.tagSvc.SetRuleActive(c.Request.Context(), whenTagID, thenTagID, *body.IsActive)
|
||||||
|
if err != nil {
|
||||||
|
respondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(c, http.StatusOK, toTagRuleJSON(*rule))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DELETE /tags/:tag_id/rules/:then_tag_id
|
// DELETE /tags/:tag_id/rules/:then_tag_id
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -191,6 +191,23 @@ func (s *TagService) CreateRule(ctx context.Context, whenTagID, thenTagID uuid.U
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRuleActive toggles a rule's is_active flag and returns the updated rule.
|
||||||
|
func (s *TagService) SetRuleActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active bool) (*domain.TagRule, error) {
|
||||||
|
if err := s.rules.SetActive(ctx, whenTagID, thenTagID, active); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules, err := s.rules.ListByTag(ctx, whenTagID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.ThenTagID == thenTagID {
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteRule removes a tag rule.
|
// DeleteRule removes a tag rule.
|
||||||
func (s *TagService) DeleteRule(ctx context.Context, whenTagID, thenTagID uuid.UUID) error {
|
func (s *TagService) DeleteRule(ctx context.Context, whenTagID, thenTagID uuid.UUID) error {
|
||||||
return s.rules.Delete(ctx, whenTagID, thenTagID)
|
return s.rules.Delete(ctx, whenTagID, thenTagID)
|
||||||
|
|||||||
@ -89,6 +89,43 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Upload with XHR so we can track progress via onProgress(0–100). */
|
||||||
|
export function uploadWithProgress<T>(
|
||||||
|
path: string,
|
||||||
|
formData: FormData,
|
||||||
|
onProgress: (pct: number) => void,
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const token = get(authStore).accessToken;
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', BASE + path);
|
||||||
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(xhr.responseText) as T);
|
||||||
|
} catch {
|
||||||
|
resolve(undefined as T);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let body: { code?: string; message?: string } = {};
|
||||||
|
try {
|
||||||
|
body = JSON.parse(xhr.responseText);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
reject(new ApiError(xhr.status, body.code ?? 'error', body.message ?? xhr.statusText));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new ApiError(0, 'network_error', 'Network error'));
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string) => request<T>(path),
|
get: <T>(path: string) => request<T>(path),
|
||||||
post: <T>(path: string, body?: unknown) =>
|
post: <T>(path: string, body?: unknown) =>
|
||||||
|
|||||||
123
frontend/src/lib/components/common/ConfirmDialog.svelte
Normal file
123
frontend/src/lib/components/common/ConfirmDialog.svelte
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel }: Props = $props();
|
||||||
|
|
||||||
|
let dialog = $state<HTMLDialogElement | undefined>();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dialog?.showModal();
|
||||||
|
return () => dialog?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === dialog) onCancel();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
onclick={handleBackdropClick}
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div class="body">
|
||||||
|
<p class="message">{message}</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn cancel" onclick={onCancel}>Cancel</button>
|
||||||
|
<button class="btn confirm" class:danger onclick={onConfirm}>{confirmLabel}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
dialog {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
max-width: min(340px, calc(100vw - 32px));
|
||||||
|
width: 100%;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::backdrop {
|
||||||
|
background-color: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.cancel {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.cancel:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.confirm {
|
||||||
|
border: none;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.confirm:hover {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.confirm.danger {
|
||||||
|
background-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.confirm.danger:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 80%, #fff);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -3,12 +3,27 @@
|
|||||||
import { authStore } from '$lib/stores/auth';
|
import { authStore } from '$lib/stores/auth';
|
||||||
import type { File } from '$lib/api/types';
|
import type { File } from '$lib/api/types';
|
||||||
|
|
||||||
|
const LONG_PRESS_MS = 400;
|
||||||
|
const DRAG_THRESHOLD = 8; // px — cancel long-press if pointer moves more than this
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
file: File;
|
file: File;
|
||||||
onclick?: (file: File) => void;
|
index: number;
|
||||||
|
selected?: boolean;
|
||||||
|
selectionMode?: boolean;
|
||||||
|
onTap?: (e: MouseEvent) => void;
|
||||||
|
/** Called when long-press fires; receives the pointerType of the gesture. */
|
||||||
|
onLongPress?: (pointerType: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { file, onclick }: Props = $props();
|
let {
|
||||||
|
file,
|
||||||
|
index,
|
||||||
|
selected = false,
|
||||||
|
selectionMode = false,
|
||||||
|
onTap,
|
||||||
|
onLongPress,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let imgSrc = $state<string | null>(null);
|
let imgSrc = $state<string | null>(null);
|
||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
@ -40,8 +55,51 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleClick() {
|
// --- Long press + drag detection ---
|
||||||
onclick?.(file);
|
let pressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let didLongPress = false;
|
||||||
|
let pressStartX = 0;
|
||||||
|
let pressStartY = 0;
|
||||||
|
let currentPointerType = '';
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
||||||
|
didLongPress = false;
|
||||||
|
pressStartX = e.clientX;
|
||||||
|
pressStartY = e.clientY;
|
||||||
|
currentPointerType = e.pointerType;
|
||||||
|
pressTimer = setTimeout(() => {
|
||||||
|
didLongPress = true;
|
||||||
|
onLongPress?.(currentPointerType);
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMoveInternal(e: PointerEvent) {
|
||||||
|
// Cancel long-press if pointer has moved significantly (user is scrolling)
|
||||||
|
if (pressTimer !== null) {
|
||||||
|
const dx = e.clientX - pressStartX;
|
||||||
|
const dy = e.clientY - pressStartY;
|
||||||
|
if (Math.hypot(dx, dy) > DRAG_THRESHOLD) {
|
||||||
|
clearTimeout(pressTimer);
|
||||||
|
pressTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPress() {
|
||||||
|
if (pressTimer !== null) {
|
||||||
|
clearTimeout(pressTimer);
|
||||||
|
pressTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(e: MouseEvent) {
|
||||||
|
if (didLongPress) {
|
||||||
|
didLongPress = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelPress();
|
||||||
|
onTap?.(e);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -49,17 +107,38 @@
|
|||||||
<div
|
<div
|
||||||
class="card"
|
class="card"
|
||||||
class:loaded={!!imgSrc}
|
class:loaded={!!imgSrc}
|
||||||
onclick={handleClick}
|
class:selected
|
||||||
|
data-file-index={index}
|
||||||
|
onpointerdown={onPointerDown}
|
||||||
|
onpointermove={onPointerMoveInternal}
|
||||||
|
onpointerup={() => { cancelPress(); didLongPress = false; }}
|
||||||
|
onpointerleave={cancelPress}
|
||||||
|
oncontextmenu={(e) => e.preventDefault()}
|
||||||
|
onclick={onClick}
|
||||||
title={file.original_name ?? undefined}
|
title={file.original_name ?? undefined}
|
||||||
>
|
>
|
||||||
{#if imgSrc}
|
{#if imgSrc}
|
||||||
<img src={imgSrc} alt={file.original_name ?? ''} class="thumb" />
|
<img src={imgSrc} alt={file.original_name ?? ''} class="thumb" draggable="false" />
|
||||||
{:else if failed}
|
{:else if failed}
|
||||||
<div class="placeholder failed" aria-label="Failed to load"></div>
|
<div class="placeholder failed" aria-label="Failed to load"></div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="placeholder loading" aria-label="Loading"></div>
|
<div class="placeholder loading" aria-label="Loading"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="overlay"></div>
|
<div class="overlay"></div>
|
||||||
|
{#if selected}
|
||||||
|
<div class="check" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
|
<circle cx="9" cy="9" r="8.5" fill="rgba(0,0,0,0.55)" stroke="white" stroke-width="1"/>
|
||||||
|
<path d="M5 9l3 3 5-5" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{:else if selectionMode}
|
||||||
|
<div class="check" aria-hidden="true">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
|
<circle cx="9" cy="9" r="8.5" fill="rgba(0,0,0,0.35)" stroke="rgba(255,255,255,0.5)" stroke-width="1"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -73,6 +152,8 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: var(--color-bg-elevated);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
@ -114,6 +195,17 @@
|
|||||||
background-color: rgba(0, 0, 0, 0.3);
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card.selected .overlay {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: 200% 0; }
|
0% { background-position: 200% 0; }
|
||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
|
|||||||
351
frontend/src/lib/components/file/FileUpload.svelte
Normal file
351
frontend/src/lib/components/file/FileUpload.svelte
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { uploadWithProgress, ApiError } from '$lib/api/client';
|
||||||
|
import type { File as ApiFile } from '$lib/api/types';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onUploaded: (file: ApiFile) => void;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onUploaded, children }: Props = $props();
|
||||||
|
|
||||||
|
// ---- Upload queue ----
|
||||||
|
type UploadStatus = 'uploading' | 'done' | 'error';
|
||||||
|
|
||||||
|
interface QueueItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
progress: number;
|
||||||
|
status: UploadStatus;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let queue = $state<QueueItem[]>([]);
|
||||||
|
let fileInput = $state<HTMLInputElement | undefined>();
|
||||||
|
|
||||||
|
let allSettled = $derived(queue.length > 0 && queue.every((i) => i.status !== 'uploading'));
|
||||||
|
|
||||||
|
// ---- File input ----
|
||||||
|
export function open() {
|
||||||
|
fileInput?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputChange(e: Event) {
|
||||||
|
const files = (e.currentTarget as HTMLInputElement).files;
|
||||||
|
if (files?.length) {
|
||||||
|
void enqueue(Array.from(files));
|
||||||
|
// Reset so the same file can be re-selected
|
||||||
|
(e.currentTarget as HTMLInputElement).value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Upload logic ----
|
||||||
|
function uid() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueue(files: globalThis.File[]) {
|
||||||
|
const items: QueueItem[] = files.map((f) => ({
|
||||||
|
id: uid(),
|
||||||
|
name: f.name,
|
||||||
|
progress: 0,
|
||||||
|
status: 'uploading',
|
||||||
|
}));
|
||||||
|
queue = [...queue, ...items];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
files.map((file, i) => uploadOne(file, items[i].id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadOne(file: globalThis.File, itemId: string) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadWithProgress<ApiFile>(
|
||||||
|
'/files',
|
||||||
|
fd,
|
||||||
|
(pct) => updateItem(itemId, { progress: pct }),
|
||||||
|
);
|
||||||
|
updateItem(itemId, { status: 'done', progress: 100 });
|
||||||
|
onUploaded(result);
|
||||||
|
} catch (e) {
|
||||||
|
const msg =
|
||||||
|
e instanceof ApiError
|
||||||
|
? e.status === 415
|
||||||
|
? `Unsupported file type`
|
||||||
|
: e.message
|
||||||
|
: 'Upload failed';
|
||||||
|
updateItem(itemId, { status: 'error', error: msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItem(id: string, patch: Partial<QueueItem>) {
|
||||||
|
queue = queue.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQueue() {
|
||||||
|
queue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Drag and drop ----
|
||||||
|
let dragCounter = $state(0);
|
||||||
|
let dragOver = $derived(dragCounter > 0);
|
||||||
|
|
||||||
|
function onDragEnter(e: DragEvent) {
|
||||||
|
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave() {
|
||||||
|
dragCounter = Math.max(0, dragCounter - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent) {
|
||||||
|
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter = 0;
|
||||||
|
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||||
|
if (files.length) void enqueue(files);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Hidden file input -->
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*,video/*"
|
||||||
|
style="display:none"
|
||||||
|
onchange={onInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Drop zone wrapper -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="drop-zone"
|
||||||
|
class:drag-over={dragOver}
|
||||||
|
ondragenter={onDragEnter}
|
||||||
|
ondragleave={onDragLeave}
|
||||||
|
ondragover={onDragOver}
|
||||||
|
ondrop={onDrop}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
|
||||||
|
{#if dragOver}
|
||||||
|
<div class="drop-overlay" aria-hidden="true">
|
||||||
|
<div class="drop-label">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">
|
||||||
|
<path d="M18 4v20M10 14l8-10 8 10" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 28h24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Drop files to upload
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload progress panel -->
|
||||||
|
{#if queue.length > 0}
|
||||||
|
<div class="upload-panel" role="status">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">
|
||||||
|
{#if allSettled}
|
||||||
|
Uploads complete
|
||||||
|
{:else}
|
||||||
|
Uploading {queue.filter((i) => i.status === 'uploading').length} file(s)…
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if allSettled}
|
||||||
|
<button class="clear-btn" onclick={clearQueue}>Dismiss</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="upload-list">
|
||||||
|
{#each queue as item (item.id)}
|
||||||
|
<li class="upload-item" class:done={item.status === 'done'} class:error={item.status === 'error'}>
|
||||||
|
<span class="item-name" title={item.name}>{item.name}</span>
|
||||||
|
<div class="item-right">
|
||||||
|
{#if item.status === 'uploading'}
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill" style="width: {item.progress}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="pct">{item.progress}%</span>
|
||||||
|
{:else if item.status === 'done'}
|
||||||
|
<svg class="icon-ok" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-label="Done">
|
||||||
|
<path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<span class="err-msg" title={item.error}>{item.error}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ---- Drop zone ---- */
|
||||||
|
.drop-zone {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 18%, rgba(0, 0, 0, 0.7));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px dashed var(--color-accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Upload panel ---- */
|
||||||
|
.upload-panel {
|
||||||
|
position: fixed;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 65px;
|
||||||
|
z-index: 110;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 16px rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 10px 12px;
|
||||||
|
animation: slide-up 0.18s ease-out;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { transform: translateY(10px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item.done .item-name {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-item.error .item-name {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
width: 80px;
|
||||||
|
height: 4px;
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 20%, var(--color-bg-elevated));
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pct {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-ok {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-msg {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
max-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
329
frontend/src/lib/components/file/FilterBar.svelte
Normal file
329
frontend/src/lib/components/file/FilterBar.svelte
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api/client';
|
||||||
|
import type { Tag, TagOffsetPage } from '$lib/api/types';
|
||||||
|
import { buildDslFilter, parseDslFilter, tokenLabel } from '$lib/utils/dsl';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Current DSL filter string (e.g. "{t=uuid1,&,t=uuid2}"). */
|
||||||
|
value?: string | null;
|
||||||
|
onApply: (filter: string | null) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value = null, onApply, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const OPERATORS = ['(', ')', '&', '|', '!'] as const;
|
||||||
|
|
||||||
|
let tags = $state<Tag[]>([]);
|
||||||
|
let search = $state('');
|
||||||
|
let tokens = $state<string[]>(parseDslFilter(value));
|
||||||
|
let tagNames = $derived(
|
||||||
|
new Map(
|
||||||
|
tags
|
||||||
|
.filter((t) => t.id && t.name)
|
||||||
|
.map((t) => [t.id as string, t.name as string]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
tokens = parseDslFilter(value ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc').then((page) => {
|
||||||
|
tags = page.items ?? [];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let filteredTags = $derived(
|
||||||
|
search.trim()
|
||||||
|
? tags.filter((t) => t.name?.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: tags,
|
||||||
|
);
|
||||||
|
|
||||||
|
function addToken(t: string) {
|
||||||
|
tokens = [...tokens, t];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeToken(i: number) {
|
||||||
|
tokens = tokens.filter((_, idx) => idx !== i);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
onApply(buildDslFilter(tokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
tokens = [];
|
||||||
|
search = '';
|
||||||
|
onApply(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Drag-and-drop reordering ---
|
||||||
|
let dragIndex = $state<number | null>(null);
|
||||||
|
let dropIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
function onDragStart(i: number, e: DragEvent) {
|
||||||
|
dragIndex = i;
|
||||||
|
e.dataTransfer!.effectAllowed = 'move';
|
||||||
|
// Set minimal drag image so the token itself acts as the ghost
|
||||||
|
e.dataTransfer!.setData('text/plain', String(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(i: number, e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer!.dropEffect = 'move';
|
||||||
|
dropIndex = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(i: number, e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragIndex === null || dragIndex === i) return;
|
||||||
|
const next = [...tokens];
|
||||||
|
const [moved] = next.splice(dragIndex, 1);
|
||||||
|
next.splice(i, 0, moved);
|
||||||
|
tokens = next;
|
||||||
|
dragIndex = null;
|
||||||
|
dropIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
dragIndex = null;
|
||||||
|
dropIndex = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<!-- Active tokens -->
|
||||||
|
<div class="active" class:empty={tokens.length === 0}>
|
||||||
|
{#if tokens.length === 0}
|
||||||
|
<span class="hint">No filter — tap a tag or operator below to build one</span>
|
||||||
|
{:else}
|
||||||
|
{#each tokens as token, i (i)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="token active-token"
|
||||||
|
class:dragging={dragIndex === i}
|
||||||
|
class:drop-before={dropIndex === i && dragIndex !== null && dragIndex !== i}
|
||||||
|
draggable="true"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
title="Drag to reorder · Click to remove"
|
||||||
|
ondragstart={(e) => onDragStart(i, e)}
|
||||||
|
ondragover={(e) => onDragOver(i, e)}
|
||||||
|
ondrop={(e) => onDrop(i, e)}
|
||||||
|
ondragend={onDragEnd}
|
||||||
|
onclick={() => removeToken(i)}
|
||||||
|
onkeydown={(e) => e.key === 'Delete' && removeToken(i)}
|
||||||
|
>
|
||||||
|
{tokenLabel(token, tagNames)}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Operator buttons -->
|
||||||
|
<div class="ops">
|
||||||
|
{#each OPERATORS as op}
|
||||||
|
<button class="token op-token" onclick={() => addToken(op)}>{op}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tag search -->
|
||||||
|
<input
|
||||||
|
class="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search tags…"
|
||||||
|
bind:value={search}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Tag list -->
|
||||||
|
<div class="tag-list">
|
||||||
|
{#each filteredTags as tag (tag.id)}
|
||||||
|
<button
|
||||||
|
class="token tag-token"
|
||||||
|
style="background-color: {tag.color ? '#' + tag.color : tag.category_color ? '#' + tag.category_color : 'var(--color-tag-default)'}"
|
||||||
|
onclick={() => addToken(`t=${tag.id}`)}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="no-tags">{search ? 'No matching tags' : 'No tags yet'}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-reset" onclick={reset}>Reset</button>
|
||||||
|
<button class="btn btn-apply" onclick={apply}>Apply</button>
|
||||||
|
<button class="btn btn-close" onclick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
position: sticky;
|
||||||
|
top: 43px; /* header height */
|
||||||
|
z-index: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 32px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active.empty {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-token {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
transition: opacity 0.15s, outline 0.1s;
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-token:hover {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-token.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-token.drop-before {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.op-token {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-bg-elevated));
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 30px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.op-token:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 35%, var(--color-bg-elevated));
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-token {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-token:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-tags {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-apply {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-apply:hover {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 20%, var(--color-bg-elevated));
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 35%, var(--color-bg-elevated));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 15%, var(--color-bg-elevated));
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
242
frontend/src/lib/components/file/TagPicker.svelte
Normal file
242
frontend/src/lib/components/file/TagPicker.svelte
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api/client';
|
||||||
|
import type { Tag, TagOffsetPage } from '$lib/api/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fileTags: Tag[];
|
||||||
|
onAdd: (tagId: string) => Promise<void>;
|
||||||
|
onRemove: (tagId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { fileTags, onAdd, onRemove }: Props = $props();
|
||||||
|
|
||||||
|
let allTags = $state<Tag[]>([]);
|
||||||
|
let search = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc').then((p) => {
|
||||||
|
allTags = p.items ?? [];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let assignedIds = $derived(new Set(fileTags.map((t) => t.id)));
|
||||||
|
|
||||||
|
let filteredAvailable = $derived(
|
||||||
|
allTags.filter(
|
||||||
|
(t) =>
|
||||||
|
!assignedIds.has(t.id) &&
|
||||||
|
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let filteredAssigned = $derived(
|
||||||
|
search.trim()
|
||||||
|
? fileTags.filter((t) => t.name?.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: fileTags,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleAdd(tagId: string) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await onAdd(tagId);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(tagId: string) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await onRemove(tagId);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagStyle(tag: Tag) {
|
||||||
|
const color = tag.color ?? tag.category_color;
|
||||||
|
return color ? `background-color: #${color}` : '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="picker" class:busy>
|
||||||
|
<!-- Assigned tags -->
|
||||||
|
{#if fileTags.length > 0}
|
||||||
|
<div class="section-label">Assigned</div>
|
||||||
|
<div class="tag-row">
|
||||||
|
{#each filteredAssigned as tag (tag.id)}
|
||||||
|
<button
|
||||||
|
class="tag assigned"
|
||||||
|
style={tagStyle(tag)}
|
||||||
|
onclick={() => handleRemove(tag.id!)}
|
||||||
|
title="Remove tag"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
<span class="remove">×</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="search-wrap">
|
||||||
|
<input
|
||||||
|
class="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search tags…"
|
||||||
|
bind:value={search}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
{#if search}
|
||||||
|
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available tags -->
|
||||||
|
{#if filteredAvailable.length > 0}
|
||||||
|
<div class="section-label">Add tag</div>
|
||||||
|
<div class="tag-row available-row">
|
||||||
|
{#each filteredAvailable as tag (tag.id)}
|
||||||
|
<button
|
||||||
|
class="tag available"
|
||||||
|
style={tagStyle(tag)}
|
||||||
|
onclick={() => handleAdd(tag.id!)}
|
||||||
|
title="Add tag"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if search.trim()}
|
||||||
|
<p class="empty">No matching tags</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker.busy {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.available-row {
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--color-tag-default);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag.assigned {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag.assigned:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag.available {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag.available:hover {
|
||||||
|
opacity: 1;
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
166
frontend/src/lib/components/layout/Header.svelte
Normal file
166
frontend/src/lib/components/layout/Header.svelte
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SortOrder } from '$lib/stores/sorting';
|
||||||
|
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sortOptions: { value: string; label: string }[];
|
||||||
|
sort: string;
|
||||||
|
order: SortOrder;
|
||||||
|
filterActive?: boolean;
|
||||||
|
onSortChange: (sort: string) => void;
|
||||||
|
onOrderToggle: () => void;
|
||||||
|
onFilterToggle: () => void;
|
||||||
|
onUpload?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
sortOptions,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
filterActive = false,
|
||||||
|
onSortChange,
|
||||||
|
onOrderToggle,
|
||||||
|
onFilterToggle,
|
||||||
|
onUpload,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<button
|
||||||
|
class="select-btn"
|
||||||
|
class:active={$selectionActive}
|
||||||
|
onclick={() => ($selectionActive ? selectionStore.exit() : selectionStore.enter())}
|
||||||
|
>
|
||||||
|
{$selectionActive ? 'Cancel' : 'Select'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if onUpload}
|
||||||
|
<button class="upload-btn icon-btn" onclick={onUpload} title="Upload files">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M8 2v9M4 6l4-4 4 4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 13h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<select
|
||||||
|
class="sort-select"
|
||||||
|
value={sort}
|
||||||
|
onchange={(e) => onSortChange((e.currentTarget as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
|
{#each sortOptions as opt}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button class="icon-btn order-btn" onclick={onOrderToggle} title={order === 'asc' ? 'Ascending' : 'Descending'}>
|
||||||
|
{#if order === 'asc'}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 10L8 6L12 10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="icon-btn filter-btn"
|
||||||
|
class:active={filterActive}
|
||||||
|
onclick={onFilterToggle}
|
||||||
|
title="Filter"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M2 4h12M4 8h8M6 12h4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn.active {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.active {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
138
frontend/src/lib/components/layout/SelectionBar.svelte
Normal file
138
frontend/src/lib/components/layout/SelectionBar.svelte
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { selectionStore, selectionCount } from '$lib/stores/selection';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onEditTags: () => void;
|
||||||
|
onAddToPool: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onEditTags, onAddToPool, onDelete }: Props = $props();
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') selectionStore.exit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div class="bar" role="toolbar" aria-label="Selection actions">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Count / deselect all -->
|
||||||
|
<button class="count" onclick={() => selectionStore.exit()} title="Clear selection">
|
||||||
|
<span class="num">{$selectionCount}</span>
|
||||||
|
<span class="label">selected</span>
|
||||||
|
<svg class="close-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
<button class="action edit-tags" onclick={onEditTags}>Edit tags</button>
|
||||||
|
<button class="action add-pool" onclick={onAddToPool}>Add to pool</button>
|
||||||
|
<button class="action delete" onclick={onDelete}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 65px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 12px 14px;
|
||||||
|
z-index: 100;
|
||||||
|
animation: slide-up 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { transform: translateY(12px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count:hover .close-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-tags {
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-tags:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-info) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-pool {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-pool:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
56
frontend/src/lib/components/tag/TagBadge.svelte
Normal file
56
frontend/src/lib/components/tag/TagBadge.svelte
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Tag } from '$lib/api/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tag: Tag;
|
||||||
|
onclick?: () => void;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { tag, onclick, size = 'md' }: Props = $props();
|
||||||
|
|
||||||
|
const color = tag.color ?? tag.category_color;
|
||||||
|
const style = color ? `background-color: #${color}` : '';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if onclick}
|
||||||
|
<button class="badge {size}" {style} {onclick} type="button">
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="badge {size}" {style}>{tag.name}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: var(--color-tag-default);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.md {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.sm {
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 7px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.badge {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.badge:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
327
frontend/src/lib/components/tag/TagRuleEditor.svelte
Normal file
327
frontend/src/lib/components/tag/TagRuleEditor.svelte
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
import type { Tag, TagOffsetPage, TagRule } from '$lib/api/types';
|
||||||
|
import TagBadge from './TagBadge.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tagId: string;
|
||||||
|
rules: TagRule[];
|
||||||
|
onRulesChange: (rules: TagRule[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { tagId, rules, onRulesChange }: Props = $props();
|
||||||
|
|
||||||
|
let allTags = $state<Tag[]>([]);
|
||||||
|
let search = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api.get<TagOffsetPage>('/tags?limit=200&sort=name&order=asc').then((p) => {
|
||||||
|
allTags = p.items ?? [];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// IDs already used in rules
|
||||||
|
let usedIds = $derived(new Set(rules.map((r) => r.then_tag_id)));
|
||||||
|
|
||||||
|
let filteredTags = $derived(
|
||||||
|
allTags.filter(
|
||||||
|
(t) =>
|
||||||
|
t.id !== tagId &&
|
||||||
|
!usedIds.has(t.id) &&
|
||||||
|
(!search.trim() || t.name?.toLowerCase().includes(search.toLowerCase())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function tagForId(id: string | undefined) {
|
||||||
|
return allTags.find((t) => t.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRule(thenTagId: string) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const rule = await api.post<TagRule>(`/tags/${tagId}/rules`, {
|
||||||
|
then_tag_id: thenTagId,
|
||||||
|
is_active: true,
|
||||||
|
apply_to_existing: false,
|
||||||
|
});
|
||||||
|
onRulesChange([...rules, rule]);
|
||||||
|
search = '';
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to add rule';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRule(rule: TagRule) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
const thenTagId = rule.then_tag_id!;
|
||||||
|
try {
|
||||||
|
const updated = await api.patch<TagRule>(`/tags/${tagId}/rules/${thenTagId}`, {
|
||||||
|
is_active: !rule.is_active,
|
||||||
|
});
|
||||||
|
onRulesChange(rules.map((r) => r.then_tag_id === thenTagId ? updated : r));
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to update rule';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRule(thenTagId: string) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await api.delete(`/tags/${tagId}/rules/${thenTagId}`);
|
||||||
|
onRulesChange(rules.filter((r) => r.then_tag_id !== thenTagId));
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to remove rule';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="editor" class:busy>
|
||||||
|
<p class="desc">
|
||||||
|
When this tag is applied, also apply:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Current rules -->
|
||||||
|
{#if rules.length > 0}
|
||||||
|
<div class="rule-list">
|
||||||
|
{#each rules as rule (rule.then_tag_id)}
|
||||||
|
{@const t = tagForId(rule.then_tag_id)}
|
||||||
|
<div class="rule-row" class:inactive={!rule.is_active}>
|
||||||
|
{#if t}
|
||||||
|
<TagBadge tag={t} size="sm" />
|
||||||
|
{:else}
|
||||||
|
<span class="unknown">{rule.then_tag_name ?? rule.then_tag_id}</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="toggle-btn"
|
||||||
|
class:active={rule.is_active}
|
||||||
|
onclick={() => toggleRule(rule)}
|
||||||
|
title={rule.is_active ? 'Deactivate rule' : 'Activate rule'}
|
||||||
|
aria-label={rule.is_active ? 'Deactivate rule' : 'Activate rule'}
|
||||||
|
>
|
||||||
|
{#if rule.is_active}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<circle cx="6" cy="6" r="2.5" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="remove-btn"
|
||||||
|
onclick={() => removeRule(rule.then_tag_id!)}
|
||||||
|
aria-label="Remove rule"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">No rules — when this tag is applied, nothing extra happens.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add rule -->
|
||||||
|
<div class="add-section">
|
||||||
|
<div class="section-label">Add rule</div>
|
||||||
|
<div class="search-wrap">
|
||||||
|
<input
|
||||||
|
class="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search tags to add…"
|
||||||
|
bind:value={search}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
{#if search}
|
||||||
|
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="tag-pick">
|
||||||
|
{#each filteredTags as t (t.id)}
|
||||||
|
<TagBadge tag={t} size="sm" onclick={() => addRule(t.id!)} />
|
||||||
|
{:else}
|
||||||
|
<span class="empty">{search.trim() ? 'No matching tags' : 'All tags already added'}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor.busy {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-row.inactive {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn.active {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 1px 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unknown {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-pick {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
max-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
65
frontend/src/lib/stores/selection.ts
Normal file
65
frontend/src/lib/stores/selection.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
|
||||||
|
interface SelectionState {
|
||||||
|
active: boolean;
|
||||||
|
ids: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSelectionStore() {
|
||||||
|
const { subscribe, update, set } = writable<SelectionState>({
|
||||||
|
active: false,
|
||||||
|
ids: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
enter() {
|
||||||
|
update((s) => ({ ...s, active: true }));
|
||||||
|
},
|
||||||
|
|
||||||
|
exit() {
|
||||||
|
set({ active: false, ids: new Set() });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle(id: string) {
|
||||||
|
update((s) => {
|
||||||
|
const ids = new Set(s.ids);
|
||||||
|
if (ids.has(id)) {
|
||||||
|
ids.delete(id);
|
||||||
|
} else {
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
// Exit selection mode automatically when last item is deselected
|
||||||
|
const active = ids.size > 0;
|
||||||
|
return { active, ids };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
select(id: string) {
|
||||||
|
update((s) => {
|
||||||
|
const ids = new Set(s.ids);
|
||||||
|
ids.add(id);
|
||||||
|
return { active: true, ids };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deselect(id: string) {
|
||||||
|
update((s) => {
|
||||||
|
const ids = new Set(s.ids);
|
||||||
|
ids.delete(id);
|
||||||
|
const active = ids.size > 0;
|
||||||
|
return { active, ids };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
set({ active: false, ids: new Set() });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectionStore = createSelectionStore();
|
||||||
|
|
||||||
|
export const selectionCount = derived(selectionStore, ($s) => $s.ids.size);
|
||||||
|
export const selectionActive = derived(selectionStore, ($s) => $s.active);
|
||||||
51
frontend/src/lib/stores/sorting.ts
Normal file
51
frontend/src/lib/stores/sorting.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export type FileSortField = 'content_datetime' | 'created' | 'original_name' | 'mime';
|
||||||
|
export type TagSortField = 'name' | 'color' | 'category_name' | 'created';
|
||||||
|
export type SortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export interface SortState<F extends string> {
|
||||||
|
sort: F;
|
||||||
|
order: SortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSortStore<F extends string>(key: string, defaults: SortState<F>) {
|
||||||
|
const stored = browser ? localStorage.getItem(key) : null;
|
||||||
|
const initial: SortState<F> = stored ? (JSON.parse(stored) as SortState<F>) : defaults;
|
||||||
|
const store = writable<SortState<F>>(initial);
|
||||||
|
|
||||||
|
store.subscribe((v) => {
|
||||||
|
if (browser) localStorage.setItem(key, JSON.stringify(v));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: store.subscribe,
|
||||||
|
setSort(sort: F) {
|
||||||
|
store.update((s) => ({ ...s, sort }));
|
||||||
|
},
|
||||||
|
setOrder(order: SortOrder) {
|
||||||
|
store.update((s) => ({ ...s, order }));
|
||||||
|
},
|
||||||
|
toggleOrder() {
|
||||||
|
store.update((s) => ({ ...s, order: s.order === 'asc' ? 'desc' : 'asc' }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fileSorting = makeSortStore<FileSortField>('sort:files', {
|
||||||
|
sort: 'created',
|
||||||
|
order: 'desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagSorting = makeSortStore<TagSortField>('sort:tags', {
|
||||||
|
sort: 'created',
|
||||||
|
order: 'desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CategorySortField = 'name' | 'color' | 'created';
|
||||||
|
|
||||||
|
export const categorySorting = makeSortStore<CategorySortField>('sort:categories', {
|
||||||
|
sort: 'name',
|
||||||
|
order: 'asc',
|
||||||
|
});
|
||||||
39
frontend/src/lib/utils/dsl.ts
Normal file
39
frontend/src/lib/utils/dsl.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Filter DSL utilities.
|
||||||
|
*
|
||||||
|
* Token format (comma-separated inside braces):
|
||||||
|
* t=<uuid> — has tag
|
||||||
|
* m=<mime> — exact MIME
|
||||||
|
* m~<pattern> — MIME LIKE pattern
|
||||||
|
* ( ) & | ! — grouping / boolean operators
|
||||||
|
*
|
||||||
|
* Example: {t=uuid1,&,!,t=uuid2} → has tag1 AND NOT tag2
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Build the filter query string value from an ordered token list. */
|
||||||
|
export function buildDslFilter(tokens: string[]): string | null {
|
||||||
|
if (tokens.length === 0) return null;
|
||||||
|
return '{' + tokens.join(',') + '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the filter query string value back into a token list. */
|
||||||
|
export function parseDslFilter(value: string | null): string[] {
|
||||||
|
if (!value) return [];
|
||||||
|
const inner = value.replace(/^\{/, '').replace(/\}$/, '').trim();
|
||||||
|
if (!inner) return [];
|
||||||
|
return inner.split(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a human-readable label for a single DSL token (for display). */
|
||||||
|
export function tokenLabel(token: string, tagNames: Map<string, string>): string {
|
||||||
|
if (token === '&') return 'AND';
|
||||||
|
if (token === '|') return 'OR';
|
||||||
|
if (token === '!') return 'NOT';
|
||||||
|
if (token === '(') return '(';
|
||||||
|
if (token === ')') return ')';
|
||||||
|
if (token.startsWith('t=')) {
|
||||||
|
const id = token.slice(2);
|
||||||
|
return tagNames.get(id) ?? token;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
389
frontend/src/routes/categories/+page.svelte
Normal file
389
frontend/src/routes/categories/+page.svelte
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
import { categorySorting, type CategorySortField } from '$lib/stores/sorting';
|
||||||
|
import type { Category, CategoryOffsetPage } from '$lib/api/types';
|
||||||
|
|
||||||
|
const LIMIT = 100;
|
||||||
|
|
||||||
|
const SORT_OPTIONS: { value: CategorySortField; label: string }[] = [
|
||||||
|
{ value: 'name', label: 'Name' },
|
||||||
|
{ value: 'color', label: 'Color' },
|
||||||
|
{ value: 'created', label: 'Created' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let categories = $state<Category[]>([]);
|
||||||
|
let total = $state(0);
|
||||||
|
let offset = $state(0);
|
||||||
|
let loading = $state(false);
|
||||||
|
let initialLoaded = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let search = $state('');
|
||||||
|
|
||||||
|
let sortState = $derived($categorySorting);
|
||||||
|
|
||||||
|
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
||||||
|
let prevKey = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (resetKey !== prevKey) {
|
||||||
|
prevKey = resetKey;
|
||||||
|
categories = [];
|
||||||
|
offset = 0;
|
||||||
|
total = 0;
|
||||||
|
initialLoaded = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!initialLoaded && !loading) void load();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: String(LIMIT),
|
||||||
|
offset: String(offset),
|
||||||
|
sort: sortState.sort,
|
||||||
|
order: sortState.order,
|
||||||
|
});
|
||||||
|
if (search.trim()) params.set('search', search.trim());
|
||||||
|
const page = await api.get<CategoryOffsetPage>(`/categories?${params}`);
|
||||||
|
categories = offset === 0 ? (page.items ?? []) : [...categories, ...(page.items ?? [])];
|
||||||
|
total = page.total ?? 0;
|
||||||
|
offset = categories.length;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to load categories';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
initialLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasMore = $derived(categories.length < total);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Categories | Tanabata</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<header class="top-bar">
|
||||||
|
<h1 class="page-title">Categories</h1>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<select
|
||||||
|
class="sort-select"
|
||||||
|
value={sortState.sort}
|
||||||
|
onchange={(e) => categorySorting.setSort((e.currentTarget as HTMLSelectElement).value as CategorySortField)}
|
||||||
|
>
|
||||||
|
{#each SORT_OPTIONS as opt}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
onclick={() => categorySorting.toggleOrder()}
|
||||||
|
title={sortState.order === 'asc' ? 'Ascending' : 'Descending'}
|
||||||
|
>
|
||||||
|
{#if sortState.order === 'asc'}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M3 9L7 5L11 9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M3 5L7 9L11 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="new-btn" onclick={() => goto('/categories/new')}>+ New</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search categories…"
|
||||||
|
value={search}
|
||||||
|
oninput={(e) => (search = (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
{#if search}
|
||||||
|
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="category-grid">
|
||||||
|
{#each categories as cat (cat.id)}
|
||||||
|
<button
|
||||||
|
class="category-pill"
|
||||||
|
style={cat.color ? `background-color: #${cat.color}` : ''}
|
||||||
|
onclick={() => goto(`/categories/${cat.id}`)}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading-row">
|
||||||
|
<span class="spinner" role="status" aria-label="Loading"></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasMore && !loading}
|
||||||
|
<button class="load-more" onclick={load}>Load more</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !loading && categories.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
{search ? 'No categories match your search.' : 'No categories yet.'}
|
||||||
|
{#if !search}
|
||||||
|
<a href="/categories/new">Create one</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-btn:hover {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 12px calc(60px + 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--color-tag-default);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-pill:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||||
|
border-top-color: var(--color-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
display: block;
|
||||||
|
margin: 16px auto 0;
|
||||||
|
padding: 8px 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 60px 20px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
401
frontend/src/routes/categories/[id]/+page.svelte
Normal file
401
frontend/src/routes/categories/[id]/+page.svelte
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
import type { Category, Tag, TagOffsetPage } from '$lib/api/types';
|
||||||
|
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
|
||||||
|
let categoryId = $derived(page.params.id);
|
||||||
|
|
||||||
|
let category = $state<Category | null>(null);
|
||||||
|
let tags = $state<Tag[]>([]);
|
||||||
|
let tagsTotal = $state(0);
|
||||||
|
let tagsOffset = $state(0);
|
||||||
|
let tagsLoading = $state(false);
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let notes = $state('');
|
||||||
|
let color = $state('#9592B5');
|
||||||
|
let isPublic = $state(false);
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
let loadError = $state('');
|
||||||
|
let saveError = $state('');
|
||||||
|
let loaded = $state(false);
|
||||||
|
let confirmDelete = $state(false);
|
||||||
|
|
||||||
|
const TAGS_LIMIT = 100;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = categoryId;
|
||||||
|
loaded = false;
|
||||||
|
loadError = '';
|
||||||
|
tags = [];
|
||||||
|
tagsOffset = 0;
|
||||||
|
tagsTotal = 0;
|
||||||
|
void api.get<Category>(`/categories/${id}`).then((cat) => {
|
||||||
|
category = cat;
|
||||||
|
name = cat.name ?? '';
|
||||||
|
notes = cat.notes ?? '';
|
||||||
|
color = cat.color ? `#${cat.color}` : '#9592B5';
|
||||||
|
isPublic = cat.is_public ?? false;
|
||||||
|
loaded = true;
|
||||||
|
}).catch((e) => {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'Failed to load category';
|
||||||
|
});
|
||||||
|
void loadTags(id, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadTags(id: string, startOffset: number) {
|
||||||
|
tagsLoading = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: String(TAGS_LIMIT),
|
||||||
|
offset: String(startOffset),
|
||||||
|
sort: 'name',
|
||||||
|
order: 'asc',
|
||||||
|
});
|
||||||
|
const p = await api.get<TagOffsetPage>(`/categories/${id}/tags?${params}`);
|
||||||
|
tags = startOffset === 0 ? (p.items ?? []) : [...tags, ...(p.items ?? [])];
|
||||||
|
tagsTotal = p.total ?? 0;
|
||||||
|
tagsOffset = tags.length;
|
||||||
|
} catch {
|
||||||
|
// non-fatal — tags section just stays empty
|
||||||
|
} finally {
|
||||||
|
tagsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tagsHasMore = $derived(tags.length < tagsTotal);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!name.trim() || saving) return;
|
||||||
|
saving = true;
|
||||||
|
saveError = '';
|
||||||
|
try {
|
||||||
|
await api.patch(`/categories/${categoryId}`, {
|
||||||
|
name: name.trim(),
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
color: color.slice(1),
|
||||||
|
is_public: isPublic,
|
||||||
|
});
|
||||||
|
goto('/categories');
|
||||||
|
} catch (e) {
|
||||||
|
saveError = e instanceof ApiError ? e.message : 'Failed to save category';
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDeleteCategory() {
|
||||||
|
confirmDelete = false;
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await api.delete(`/categories/${categoryId}`);
|
||||||
|
goto('/categories');
|
||||||
|
} catch (e) {
|
||||||
|
saveError = e instanceof ApiError ? e.message : 'Failed to delete category';
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{category?.name ?? 'Category'} | Tanabata</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<header class="top-bar">
|
||||||
|
<button class="back-btn" onclick={() => goto('/categories')} aria-label="Back">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 class="page-title">{category?.name ?? 'Category'}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{#if loadError}
|
||||||
|
<p class="error" role="alert">{loadError}</p>
|
||||||
|
{:else if !loaded}
|
||||||
|
<div class="loading-row">
|
||||||
|
<span class="spinner" role="status" aria-label="Loading"></span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if saveError}
|
||||||
|
<p class="error" role="alert">{saveError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="form" onsubmit={(e) => { e.preventDefault(); void save(); }}>
|
||||||
|
<div class="row-fields">
|
||||||
|
<div class="field" style="flex: 1">
|
||||||
|
<label class="label" for="name">Name <span class="required">*</span></label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
placeholder="Category name"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field color-field">
|
||||||
|
<label class="label" for="color">Color</label>
|
||||||
|
<input id="color" class="color-input" type="color" bind:value={color} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="notes">Notes</label>
|
||||||
|
<textarea id="notes" class="textarea" rows="3" bind:value={notes} placeholder="Optional notes…"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="label">Public</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle"
|
||||||
|
class:on={isPublic}
|
||||||
|
onclick={() => (isPublic = !isPublic)}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isPublic}
|
||||||
|
aria-label="Public"
|
||||||
|
>
|
||||||
|
<span class="thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-row">
|
||||||
|
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="delete-btn" onclick={() => (confirmDelete = true)} disabled={deleting}>
|
||||||
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Tags in this category -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">
|
||||||
|
Tags
|
||||||
|
{#if tagsTotal > 0}<span class="count">({tagsTotal})</span>{/if}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if tagsLoading && tags.length === 0}
|
||||||
|
<div class="loading-row">
|
||||||
|
<span class="spinner" role="status" aria-label="Loading"></span>
|
||||||
|
</div>
|
||||||
|
{:else if tags.length === 0}
|
||||||
|
<p class="empty-tags">No tags in this category.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="tag-grid">
|
||||||
|
{#each tags as tag (tag.id)}
|
||||||
|
<TagBadge {tag} onclick={() => goto(`/tags/${tag.id}`)} size="sm" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tagsHasMore}
|
||||||
|
<button
|
||||||
|
class="load-more"
|
||||||
|
onclick={() => loadTags(categoryId, tagsOffset)}
|
||||||
|
disabled={tagsLoading}
|
||||||
|
>
|
||||||
|
{tagsLoading ? 'Loading…' : 'Load more'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if confirmDelete}
|
||||||
|
<ConfirmDialog
|
||||||
|
message={`Delete category "${name}"? Tags in this category will be unassigned.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
danger
|
||||||
|
onConfirm={doDeleteCategory}
|
||||||
|
onCancel={() => (confirmDelete = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 10px; min-height: 44px;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
|
border: none; background: none;
|
||||||
|
color: var(--color-text-primary); cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
|
||||||
|
|
||||||
|
.page-title { font-size: 1rem; font-weight: 600; margin: 0; }
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 16px 14px calc(60px + 16px);
|
||||||
|
display: flex; flex-direction: column; gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-row { display: flex; justify-content: center; padding: 40px; }
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: block; width: 28px; height: 28px;
|
||||||
|
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||||
|
border-top-color: var(--color-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
|
.row-fields { display: flex; gap: 10px; align-items: flex-end; }
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
|
||||||
|
.color-field { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.75rem; font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required { color: var(--color-danger); }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%; box-sizing: border-box;
|
||||||
|
height: 36px; padding: 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem; font-family: inherit; outline: none;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--color-accent); }
|
||||||
|
|
||||||
|
.color-input {
|
||||||
|
width: 50px; height: 36px; padding: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%; box-sizing: border-box; padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem; font-family: inherit;
|
||||||
|
resize: vertical; outline: none; min-height: 70px;
|
||||||
|
}
|
||||||
|
.textarea:focus { border-color: var(--color-accent); }
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.toggle-row .label { margin: 0; }
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: relative; width: 44px; height: 26px;
|
||||||
|
border-radius: 13px; border: none;
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
|
cursor: pointer; transition: background-color 0.2s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle.on { background-color: var(--color-accent); }
|
||||||
|
.thumb {
|
||||||
|
position: absolute; top: 3px; left: 3px;
|
||||||
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
background-color: #fff; transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.toggle.on .thumb { transform: translateX(18px); }
|
||||||
|
|
||||||
|
.action-row { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
flex: 1; height: 42px; border-radius: 8px; border: none;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
|
||||||
|
.submit-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
height: 42px; padding: 0 18px; border-radius: 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-danger) 50%, transparent);
|
||||||
|
background: none; color: var(--color-danger);
|
||||||
|
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.delete-btn:hover:not(:disabled) { background-color: color-mix(in srgb, var(--color-danger) 12%, transparent); }
|
||||||
|
.delete-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.section { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.75rem; font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
display: flex; gap: 6px; align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tags {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.error { color: var(--color-danger); font-size: 0.875rem; margin: 0; }
|
||||||
|
</style>
|
||||||
199
frontend/src/routes/categories/new/+page.svelte
Normal file
199
frontend/src/routes/categories/new/+page.svelte
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let notes = $state('');
|
||||||
|
let color = $state('#9592B5');
|
||||||
|
let isPublic = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!name.trim() || saving) return;
|
||||||
|
saving = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await api.post('/categories', {
|
||||||
|
name: name.trim(),
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
color: color.slice(1),
|
||||||
|
is_public: isPublic,
|
||||||
|
});
|
||||||
|
goto('/categories');
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to create category';
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>New Category | Tanabata</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<header class="top-bar">
|
||||||
|
<button class="back-btn" onclick={() => goto('/categories')} aria-label="Back">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 class="page-title">New Category</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="form" onsubmit={(e) => { e.preventDefault(); void submit(); }}>
|
||||||
|
<div class="row-fields">
|
||||||
|
<div class="field" style="flex: 1">
|
||||||
|
<label class="label" for="name">Name <span class="required">*</span></label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
placeholder="Category name"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field color-field">
|
||||||
|
<label class="label" for="color">Color</label>
|
||||||
|
<input id="color" class="color-input" type="color" bind:value={color} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="notes">Notes</label>
|
||||||
|
<textarea id="notes" class="textarea" rows="3" bind:value={notes} placeholder="Optional notes…"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="label">Public</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle"
|
||||||
|
class:on={isPublic}
|
||||||
|
onclick={() => (isPublic = !isPublic)}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isPublic}
|
||||||
|
aria-label="Public"
|
||||||
|
>
|
||||||
|
<span class="thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
||||||
|
{saving ? 'Creating…' : 'Create category'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 10px; min-height: 44px;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
|
border: none; background: none;
|
||||||
|
color: var(--color-text-primary); cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
|
||||||
|
|
||||||
|
.page-title { font-size: 1rem; font-weight: 600; margin: 0; }
|
||||||
|
|
||||||
|
main { flex: 1; overflow-y: auto; padding: 16px 14px calc(60px + 16px); }
|
||||||
|
|
||||||
|
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
|
.row-fields { display: flex; gap: 10px; align-items: flex-end; }
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
|
||||||
|
.color-field { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.75rem; font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required { color: var(--color-danger); }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%; box-sizing: border-box;
|
||||||
|
height: 36px; padding: 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem; font-family: inherit; outline: none;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--color-accent); }
|
||||||
|
|
||||||
|
.color-input {
|
||||||
|
width: 50px; height: 36px; padding: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%; box-sizing: border-box; padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem; font-family: inherit;
|
||||||
|
resize: vertical; outline: none; min-height: 70px;
|
||||||
|
}
|
||||||
|
.textarea:focus { border-color: var(--color-accent); }
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.toggle-row .label { margin: 0; }
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: relative; width: 44px; height: 26px;
|
||||||
|
border-radius: 13px; border: none;
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
|
cursor: pointer; transition: background-color 0.2s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle.on { background-color: var(--color-accent); }
|
||||||
|
.thumb {
|
||||||
|
position: absolute; top: 3px; left: 3px;
|
||||||
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
background-color: #fff; transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.toggle.on .thumb { transform: translateX(18px); }
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%; height: 42px; border-radius: 8px; border: none;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
|
||||||
|
.submit-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.error { color: var(--color-danger); font-size: 0.875rem; }
|
||||||
|
</style>
|
||||||
@ -1,31 +1,76 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { ApiError } from '$lib/api/client';
|
import { ApiError } from '$lib/api/client';
|
||||||
import FileCard from '$lib/components/file/FileCard.svelte';
|
import FileCard from '$lib/components/file/FileCard.svelte';
|
||||||
|
import FileUpload from '$lib/components/file/FileUpload.svelte';
|
||||||
|
import FilterBar from '$lib/components/file/FilterBar.svelte';
|
||||||
|
import Header from '$lib/components/layout/Header.svelte';
|
||||||
|
import SelectionBar from '$lib/components/layout/SelectionBar.svelte';
|
||||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||||
|
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
|
||||||
|
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
||||||
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
import { parseDslFilter } from '$lib/utils/dsl';
|
||||||
import type { File, FileCursorPage } from '$lib/api/types';
|
import type { File, FileCursorPage } from '$lib/api/types';
|
||||||
|
|
||||||
|
let uploader = $state<{ open: () => void } | undefined>();
|
||||||
|
let confirmDeleteFiles = $state(false);
|
||||||
|
|
||||||
|
function handleUploaded(file: File) {
|
||||||
|
files = [file, ...files];
|
||||||
|
}
|
||||||
|
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
const FILE_SORT_OPTIONS = [
|
||||||
|
{ value: 'created', label: 'Created' },
|
||||||
|
{ value: 'content_datetime', label: 'Date taken' },
|
||||||
|
{ value: 'original_name', label: 'Name' },
|
||||||
|
{ value: 'mime', label: 'Type' },
|
||||||
|
];
|
||||||
|
|
||||||
let files = $state<File[]>([]);
|
let files = $state<File[]>([]);
|
||||||
let nextCursor = $state<string | null>(null);
|
let nextCursor = $state<string | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let hasMore = $state(true);
|
let hasMore = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let filterOpen = $state(false);
|
||||||
|
|
||||||
|
let filterParam = $derived(page.url.searchParams.get('filter'));
|
||||||
|
let activeTokens = $derived(parseDslFilter(filterParam));
|
||||||
|
let sortState = $derived($fileSorting);
|
||||||
|
|
||||||
|
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
|
||||||
|
let prevKey = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (resetKey !== prevKey) {
|
||||||
|
prevKey = resetKey;
|
||||||
|
files = [];
|
||||||
|
nextCursor = null;
|
||||||
|
hasMore = true;
|
||||||
|
error = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loading || !hasMore) return;
|
if (loading || !hasMore) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ limit: String(LIMIT) });
|
const params = new URLSearchParams({
|
||||||
|
limit: String(LIMIT),
|
||||||
|
sort: sortState.sort,
|
||||||
|
order: sortState.order,
|
||||||
|
});
|
||||||
if (nextCursor) params.set('cursor', nextCursor);
|
if (nextCursor) params.set('cursor', nextCursor);
|
||||||
|
if (filterParam) params.set('filter', filterParam);
|
||||||
const page = await api.get<FileCursorPage>(`/files?${params}`);
|
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
files = [...files, ...(page.items ?? [])];
|
files = [...files, ...(res.items ?? [])];
|
||||||
nextCursor = page.next_cursor ?? null;
|
nextCursor = res.next_cursor ?? null;
|
||||||
hasMore = !!page.next_cursor;
|
hasMore = !!res.next_cursor;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof ApiError ? err.message : 'Failed to load files';
|
error = err instanceof ApiError ? err.message : 'Failed to load files';
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
@ -33,6 +78,101 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyFilter(filter: string | null) {
|
||||||
|
const url = new URL(page.url);
|
||||||
|
if (filter) {
|
||||||
|
url.searchParams.set('filter', filter);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('filter');
|
||||||
|
}
|
||||||
|
goto(url.toString(), { replaceState: true });
|
||||||
|
filterOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFile(file: File) {
|
||||||
|
if (file.id) goto(`/files/${file.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Selection logic ----
|
||||||
|
|
||||||
|
let lastSelectedIdx = $state<number | null>(null);
|
||||||
|
|
||||||
|
function handleTap(file: File, idx: number, e: MouseEvent) {
|
||||||
|
if (!$selectionActive) {
|
||||||
|
openFile(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.shiftKey && lastSelectedIdx !== null) {
|
||||||
|
// Range-select between lastSelectedIdx and idx (desktop)
|
||||||
|
const from = Math.min(lastSelectedIdx, idx);
|
||||||
|
const to = Math.max(lastSelectedIdx, idx);
|
||||||
|
for (let i = from; i <= to; i++) {
|
||||||
|
if (files[i]?.id) selectionStore.select(files[i].id!);
|
||||||
|
}
|
||||||
|
lastSelectedIdx = idx;
|
||||||
|
} else {
|
||||||
|
if (file.id) selectionStore.toggle(file.id);
|
||||||
|
lastSelectedIdx = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLongPress(file: File, idx: number, pointerType: string) {
|
||||||
|
// Determine drag mode from whether this card is already selected
|
||||||
|
const alreadySelected = $selectionStore.ids.has(file.id!);
|
||||||
|
if (alreadySelected) {
|
||||||
|
selectionStore.deselect(file.id!);
|
||||||
|
dragMode = 'deselect';
|
||||||
|
} else {
|
||||||
|
selectionStore.select(file.id!);
|
||||||
|
dragMode = 'select';
|
||||||
|
}
|
||||||
|
lastSelectedIdx = idx;
|
||||||
|
// Only enter drag-select for touch — shift+click covers desktop range selection
|
||||||
|
if (pointerType === 'touch') dragSelecting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Drag-to-select / deselect (touch only) ----
|
||||||
|
// Entered only after a long-press (400ms stillness), so by the time we
|
||||||
|
// add the touchmove listener the scroll gesture hasn't started yet.
|
||||||
|
// A non-passive touchmove listener lets us call preventDefault() to block
|
||||||
|
// scroll while the user slides their finger across cards.
|
||||||
|
|
||||||
|
let dragSelecting = $state(false);
|
||||||
|
let dragMode = $state<'select' | 'deselect'>('select');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!dragSelecting) return;
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
e.preventDefault(); // block scroll while drag-selecting
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||||
|
const card = el?.closest<HTMLElement>('[data-file-index]');
|
||||||
|
if (!card) return;
|
||||||
|
const idx = parseInt(card.dataset.fileIndex ?? '');
|
||||||
|
if (isNaN(idx) || !files[idx]?.id) return;
|
||||||
|
if (dragMode === 'select') {
|
||||||
|
selectionStore.select(files[idx].id!);
|
||||||
|
} else {
|
||||||
|
selectionStore.deselect(files[idx].id!);
|
||||||
|
}
|
||||||
|
lastSelectedIdx = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
dragSelecting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', onTouchEnd);
|
||||||
|
document.addEventListener('touchcancel', onTouchEnd);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('touchmove', onTouchMove);
|
||||||
|
document.removeEventListener('touchend', onTouchEnd);
|
||||||
|
document.removeEventListener('touchcancel', onTouchEnd);
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@ -40,25 +180,81 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<main>
|
<Header
|
||||||
{#if error}
|
sortOptions={FILE_SORT_OPTIONS}
|
||||||
<p class="error" role="alert">{error}</p>
|
sort={sortState.sort}
|
||||||
{/if}
|
order={sortState.order}
|
||||||
|
filterActive={activeTokens.length > 0 || filterOpen}
|
||||||
|
onSortChange={(s) => fileSorting.setSort(s as FileSortField)}
|
||||||
|
onOrderToggle={() => fileSorting.toggleOrder()}
|
||||||
|
onFilterToggle={() => (filterOpen = !filterOpen)}
|
||||||
|
onUpload={() => uploader?.open()}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="grid">
|
{#if filterOpen}
|
||||||
{#each files as file (file.id)}
|
<FilterBar
|
||||||
<FileCard {file} />
|
value={filterParam}
|
||||||
{/each}
|
onApply={applyFilter}
|
||||||
</div>
|
onClose={() => (filterOpen = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<InfiniteScroll {loading} {hasMore} onLoadMore={loadMore} />
|
<FileUpload bind:this={uploader} onUploaded={handleUploaded}>
|
||||||
|
<main>
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if !loading && !hasMore && files.length === 0}
|
<div class="grid">
|
||||||
<div class="empty">No files yet.</div>
|
{#each files as file, i (file.id)}
|
||||||
{/if}
|
<FileCard
|
||||||
</main>
|
{file}
|
||||||
|
index={i}
|
||||||
|
selected={$selectionStore.ids.has(file.id ?? '')}
|
||||||
|
selectionMode={$selectionActive}
|
||||||
|
onTap={(e) => handleTap(file, i, e)}
|
||||||
|
onLongPress={(pt) => handleLongPress(file, i, pt)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InfiniteScroll {loading} {hasMore} onLoadMore={loadMore} />
|
||||||
|
|
||||||
|
{#if !loading && !hasMore && files.length === 0}
|
||||||
|
<div class="empty">No files yet.</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</FileUpload>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if $selectionActive}
|
||||||
|
<SelectionBar
|
||||||
|
onEditTags={() => {/* TODO */}}
|
||||||
|
onAddToPool={() => {/* TODO */}}
|
||||||
|
onDelete={() => (confirmDeleteFiles = true)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmDeleteFiles}
|
||||||
|
<ConfirmDialog
|
||||||
|
message={`Move ${$selectionStore.ids.size} file(s) to trash?`}
|
||||||
|
confirmLabel="Move to trash"
|
||||||
|
danger
|
||||||
|
onConfirm={async () => {
|
||||||
|
const ids = [...$selectionStore.ids];
|
||||||
|
confirmDeleteFiles = false;
|
||||||
|
selectionStore.exit();
|
||||||
|
try {
|
||||||
|
await api.post('/files/bulk/delete', { file_ids: ids });
|
||||||
|
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
||||||
|
} catch {
|
||||||
|
// silently ignore — file list already updated optimistically
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => (confirmDeleteFiles = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -100,4 +296,4 @@
|
|||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
588
frontend/src/routes/files/[id]/+page.svelte
Normal file
588
frontend/src/routes/files/[id]/+page.svelte
Normal file
@ -0,0 +1,588 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
import { authStore } from '$lib/stores/auth';
|
||||||
|
import { fileSorting } from '$lib/stores/sorting';
|
||||||
|
import TagPicker from '$lib/components/file/TagPicker.svelte';
|
||||||
|
import type { File, Tag, FileCursorPage } from '$lib/api/types';
|
||||||
|
|
||||||
|
// ---- State ----
|
||||||
|
let fileId = $derived(page.params.id);
|
||||||
|
|
||||||
|
let file = $state<File | null>(null);
|
||||||
|
let fileTags = $state<Tag[]>([]);
|
||||||
|
let previewSrc = $state<string | null>(null);
|
||||||
|
let prevFile = $state<File | null>(null);
|
||||||
|
let nextFile = $state<File | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
// Editable fields (initialised on load)
|
||||||
|
let notes = $state('');
|
||||||
|
let contentDatetime = $state('');
|
||||||
|
let isPublic = $state(false);
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
|
let exifEntries = $derived(
|
||||||
|
file?.exif ? Object.entries(file.exif as Record<string, unknown>) : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Load ----
|
||||||
|
$effect(() => {
|
||||||
|
if (!fileId) return;
|
||||||
|
const id = fileId; // snapshot — don't re-run if other state changes
|
||||||
|
// Revoke old blob URL without tracking previewSrc as a dependency
|
||||||
|
untrack(() => {
|
||||||
|
if (previewSrc) URL.revokeObjectURL(previewSrc);
|
||||||
|
previewSrc = null;
|
||||||
|
});
|
||||||
|
void loadPage(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPage(id: string) {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const [fileData, tags] = await Promise.all([
|
||||||
|
api.get<File>(`/files/${id}`),
|
||||||
|
api.get<Tag[]>(`/files/${id}/tags`),
|
||||||
|
]);
|
||||||
|
file = fileData;
|
||||||
|
fileTags = tags;
|
||||||
|
notes = fileData.notes ?? '';
|
||||||
|
contentDatetime = fileData.content_datetime
|
||||||
|
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
|
||||||
|
: '';
|
||||||
|
isPublic = fileData.is_public ?? false;
|
||||||
|
dirty = false;
|
||||||
|
|
||||||
|
void fetchPreview(id);
|
||||||
|
void loadNeighbors(id);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to load file';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPreview(id: string) {
|
||||||
|
const token = get(authStore).accessToken;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/files/${id}/preview`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const blob = await res.blob();
|
||||||
|
previewSrc = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// non-critical — thumbnail stays as fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNeighbors(id: string) {
|
||||||
|
const sort = get(fileSorting);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
anchor: id,
|
||||||
|
limit: '3',
|
||||||
|
sort: sort.sort,
|
||||||
|
order: sort.order,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
|
const items = result.items ?? [];
|
||||||
|
const idx = items.findIndex((f) => f.id === id);
|
||||||
|
prevFile = idx > 0 ? items[idx - 1] : null;
|
||||||
|
nextFile = idx >= 0 && idx < items.length - 1 ? items[idx + 1] : null;
|
||||||
|
} catch {
|
||||||
|
// non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Save ----
|
||||||
|
async function save() {
|
||||||
|
if (!file || saving) return;
|
||||||
|
saving = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const updated = await api.patch<File>(`/files/${file.id}`, {
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
content_datetime: contentDatetime
|
||||||
|
? new Date(contentDatetime).toISOString()
|
||||||
|
: undefined,
|
||||||
|
is_public: isPublic,
|
||||||
|
});
|
||||||
|
file = updated;
|
||||||
|
dirty = false;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to save';
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tags ----
|
||||||
|
async function addTag(tagId: string) {
|
||||||
|
const updated = await api.put<Tag[]>(`/files/${fileId}/tags/${tagId}`);
|
||||||
|
fileTags = updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTag(tagId: string) {
|
||||||
|
await api.delete(`/files/${fileId}/tags/${tagId}`);
|
||||||
|
fileTags = fileTags.filter((t) => t.id !== tagId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Navigation ----
|
||||||
|
function navigateTo(f: File | null) {
|
||||||
|
if (f?.id) goto(`/files/${f.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
|
if (e.key === 'ArrowLeft') navigateTo(prevFile);
|
||||||
|
if (e.key === 'ArrowRight') navigateTo(nextFile);
|
||||||
|
if (e.key === 'Escape') goto('/files');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
function formatDatetime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>
|
||||||
|
{file?.original_name ?? fileId} | Tanabata
|
||||||
|
</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div class="viewer-page">
|
||||||
|
<!-- Top bar -->
|
||||||
|
<div class="top-bar">
|
||||||
|
<button class="back-btn" onclick={() => goto('/files')} aria-label="Back to files">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="filename">{file?.original_name ?? ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div class="preview-wrap">
|
||||||
|
{#if previewSrc}
|
||||||
|
<img src={previewSrc} alt={file?.original_name ?? ''} class="preview-img" />
|
||||||
|
{:else if loading}
|
||||||
|
<div class="preview-placeholder shimmer"></div>
|
||||||
|
{:else}
|
||||||
|
<div class="preview-placeholder failed"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Prev / Next -->
|
||||||
|
{#if prevFile}
|
||||||
|
<button
|
||||||
|
class="nav-btn nav-prev"
|
||||||
|
onclick={() => navigateTo(prevFile)}
|
||||||
|
aria-label="Previous file"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path d="M11 3L5 9L11 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if nextFile}
|
||||||
|
<button
|
||||||
|
class="nav-btn nav-next"
|
||||||
|
onclick={() => navigateTo(nextFile)}
|
||||||
|
aria-label="Next file"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path d="M7 3L13 9L7 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata panel -->
|
||||||
|
<div class="meta-panel">
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if file}
|
||||||
|
<!-- File info -->
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="mime">{file.mime_type}</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span class="created">Added {formatDatetime(file.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit form -->
|
||||||
|
<section class="section">
|
||||||
|
<label class="field-label" for="notes">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
class="textarea"
|
||||||
|
rows="3"
|
||||||
|
bind:value={notes}
|
||||||
|
oninput={() => (dirty = true)}
|
||||||
|
placeholder="Add notes…"
|
||||||
|
></textarea>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<label class="field-label" for="datetime">Date taken</label>
|
||||||
|
<input
|
||||||
|
id="datetime"
|
||||||
|
type="datetime-local"
|
||||||
|
class="input"
|
||||||
|
bind:value={contentDatetime}
|
||||||
|
oninput={() => (dirty = true)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section toggle-row">
|
||||||
|
<span class="field-label">Public</span>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={isPublic}
|
||||||
|
onclick={() => { isPublic = !isPublic; dirty = true; }}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isPublic}
|
||||||
|
aria-label="Public"
|
||||||
|
>
|
||||||
|
<span class="thumb"></span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="save-btn"
|
||||||
|
onclick={save}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="field-label">Tags</div>
|
||||||
|
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- EXIF -->
|
||||||
|
{#if exifEntries.length > 0}
|
||||||
|
<section class="section">
|
||||||
|
<div class="field-label">EXIF</div>
|
||||||
|
<dl class="exif">
|
||||||
|
{#each exifEntries as [key, val]}
|
||||||
|
<dt>{key}</dt>
|
||||||
|
<dd>{String(val)}</dd>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{:else if !loading}
|
||||||
|
<p class="empty">File not found.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.viewer-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding-bottom: 70px; /* clear navbar */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Top bar ---- */
|
||||||
|
.top-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filename {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Preview ---- */
|
||||||
|
.preview-wrap {
|
||||||
|
position: relative;
|
||||||
|
background-color: #000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
/* Fill viewport below the top bar (44px) */
|
||||||
|
height: calc(100dvh - 44px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder.shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#111 25%,
|
||||||
|
#222 50%,
|
||||||
|
#111 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder.failed {
|
||||||
|
background-color: #1a1010;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Nav buttons ---- */
|
||||||
|
.nav-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background-color: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-prev { left: 10px; }
|
||||||
|
.nav-next { right: 10px; }
|
||||||
|
|
||||||
|
/* ---- Metadata panel ---- */
|
||||||
|
.meta-panel {
|
||||||
|
padding: 14px 14px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep { opacity: 0.4; }
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
min-height: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Toggle ---- */
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row .field-label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 13px;
|
||||||
|
border: none;
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on .thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Save button ---- */
|
||||||
|
.save-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
transition: background-color 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- EXIF ---- */
|
||||||
|
.exif {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 3px 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Misc ---- */
|
||||||
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
374
frontend/src/routes/tags/+page.svelte
Normal file
374
frontend/src/routes/tags/+page.svelte
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
import { tagSorting, type TagSortField } from '$lib/stores/sorting';
|
||||||
|
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
||||||
|
import type { Tag, TagOffsetPage } from '$lib/api/types';
|
||||||
|
|
||||||
|
const LIMIT = 100;
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ value: 'name', label: 'Name' },
|
||||||
|
{ value: 'created', label: 'Created' },
|
||||||
|
{ value: 'color', label: 'Color' },
|
||||||
|
{ value: 'category_name', label: 'Category' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let tags = $state<Tag[]>([]);
|
||||||
|
let total = $state(0);
|
||||||
|
let offset = $state(0);
|
||||||
|
let loading = $state(false);
|
||||||
|
let initialLoaded = $state(false); // true once first page loaded for current key
|
||||||
|
let error = $state('');
|
||||||
|
let search = $state('');
|
||||||
|
let searchDebounce: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
let sortState = $derived($tagSorting);
|
||||||
|
|
||||||
|
// Reset + reload on sort or search change
|
||||||
|
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${search}`);
|
||||||
|
let prevKey = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (resetKey !== prevKey) {
|
||||||
|
prevKey = resetKey;
|
||||||
|
tags = [];
|
||||||
|
offset = 0;
|
||||||
|
total = 0;
|
||||||
|
initialLoaded = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger load after reset (only once per key)
|
||||||
|
$effect(() => {
|
||||||
|
if (!initialLoaded && !loading) void load();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: String(LIMIT),
|
||||||
|
offset: String(offset),
|
||||||
|
sort: sortState.sort,
|
||||||
|
order: sortState.order,
|
||||||
|
});
|
||||||
|
if (search.trim()) params.set('search', search.trim());
|
||||||
|
const page = await api.get<TagOffsetPage>(`/tags?${params}`);
|
||||||
|
tags = offset === 0 ? (page.items ?? []) : [...tags, ...(page.items ?? [])];
|
||||||
|
total = page.total ?? 0;
|
||||||
|
offset = tags.length;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to load tags';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
initialLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch(e: Event) {
|
||||||
|
search = (e.currentTarget as HTMLInputElement).value;
|
||||||
|
clearTimeout(searchDebounce);
|
||||||
|
searchDebounce = setTimeout(() => {}, 0); // reactive reset already handles it
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasMore = $derived(tags.length < total);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Tags | Tanabata</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<header class="top-bar">
|
||||||
|
<h1 class="page-title">Tags</h1>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<select
|
||||||
|
class="sort-select"
|
||||||
|
value={sortState.sort}
|
||||||
|
onchange={(e) => tagSorting.setSort((e.currentTarget as HTMLSelectElement).value as TagSortField)}
|
||||||
|
>
|
||||||
|
{#each SORT_OPTIONS as opt}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
onclick={() => tagSorting.toggleOrder()}
|
||||||
|
title={sortState.order === 'asc' ? 'Ascending' : 'Descending'}
|
||||||
|
>
|
||||||
|
{#if sortState.order === 'asc'}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M3 9L7 5L11 9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M3 5L7 9L11 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="new-btn" onclick={() => goto('/tags/new')}>+ New</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search tags…"
|
||||||
|
value={search}
|
||||||
|
oninput={onSearch}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
{#if search}
|
||||||
|
<button class="search-clear" onclick={() => (search = '')} aria-label="Clear search">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="tag-grid">
|
||||||
|
{#each tags as tag (tag.id)}
|
||||||
|
<TagBadge {tag} onclick={() => goto(`/tags/${tag.id}`)} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading-row">
|
||||||
|
<span class="spinner" role="status" aria-label="Loading"></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasMore && !loading}
|
||||||
|
<button class="load-more" onclick={load}>Load more</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !loading && tags.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
{search ? 'No tags match your search.' : 'No tags yet.'}
|
||||||
|
{#if !search}
|
||||||
|
<a href="/tags/new">Create one</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-btn:hover {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 12px calc(60px + 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||||
|
border-top-color: var(--color-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
display: block;
|
||||||
|
margin: 16px auto 0;
|
||||||
|
padding: 8px 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 60px 20px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
328
frontend/src/routes/tags/[id]/+page.svelte
Normal file
328
frontend/src/routes/tags/[id]/+page.svelte
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
import type { Category, CategoryOffsetPage, Tag, TagRule } from '$lib/api/types';
|
||||||
|
import TagRuleEditor from '$lib/components/tag/TagRuleEditor.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
|
||||||
|
let tagId = $derived(page.params.id);
|
||||||
|
|
||||||
|
let tag = $state<Tag | null>(null);
|
||||||
|
let categories = $state<Category[]>([]);
|
||||||
|
let rules = $state<TagRule[]>([]);
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let notes = $state('');
|
||||||
|
let color = $state('#444455');
|
||||||
|
let categoryId = $state('');
|
||||||
|
let isPublic = $state(false);
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
let loadError = $state('');
|
||||||
|
let saveError = $state('');
|
||||||
|
let confirmDelete = $state(false);
|
||||||
|
|
||||||
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = tagId;
|
||||||
|
loaded = false;
|
||||||
|
loadError = '';
|
||||||
|
void Promise.all([
|
||||||
|
api.get<Tag>(`/tags/${id}`),
|
||||||
|
api.get<CategoryOffsetPage>('/categories?limit=200&sort=name&order=asc'),
|
||||||
|
api.get<TagRule[]>(`/tags/${id}/rules`).catch(() => [] as TagRule[]),
|
||||||
|
]).then(([t, cats, r]) => {
|
||||||
|
tag = t;
|
||||||
|
categories = cats.items ?? [];
|
||||||
|
rules = r;
|
||||||
|
|
||||||
|
name = t.name ?? '';
|
||||||
|
notes = t.notes ?? '';
|
||||||
|
color = t.color ? `#${t.color}` : '#444455';
|
||||||
|
categoryId = t.category_id ?? '';
|
||||||
|
isPublic = t.is_public ?? false;
|
||||||
|
loaded = true;
|
||||||
|
}).catch((e) => {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'Failed to load tag';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!name.trim() || saving) return;
|
||||||
|
saving = true;
|
||||||
|
saveError = '';
|
||||||
|
try {
|
||||||
|
await api.patch(`/tags/${tagId}`, {
|
||||||
|
name: name.trim(),
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
color: color.slice(1),
|
||||||
|
category_id: categoryId || null,
|
||||||
|
is_public: isPublic,
|
||||||
|
});
|
||||||
|
goto('/tags');
|
||||||
|
} catch (e) {
|
||||||
|
saveError = e instanceof ApiError ? e.message : 'Failed to save tag';
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDeleteTag() {
|
||||||
|
confirmDelete = false;
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await api.delete(`/tags/${tagId}`);
|
||||||
|
goto('/tags');
|
||||||
|
} catch (e) {
|
||||||
|
saveError = e instanceof ApiError ? e.message : 'Failed to delete tag';
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{tag?.name ?? 'Tag'} | Tanabata</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<header class="top-bar">
|
||||||
|
<button class="back-btn" onclick={() => goto('/tags')} aria-label="Back">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 class="page-title">{tag?.name ?? 'Tag'}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{#if loadError}
|
||||||
|
<p class="error" role="alert">{loadError}</p>
|
||||||
|
{:else if !loaded}
|
||||||
|
<div class="loading-row">
|
||||||
|
<span class="spinner" role="status" aria-label="Loading"></span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if saveError}
|
||||||
|
<p class="error" role="alert">{saveError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="form" onsubmit={(e) => { e.preventDefault(); void save(); }}>
|
||||||
|
<div class="row-fields">
|
||||||
|
<div class="field" style="flex: 1">
|
||||||
|
<label class="label" for="name">Name <span class="required">*</span></label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
placeholder="Tag name"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field color-field">
|
||||||
|
<label class="label" for="color">Color</label>
|
||||||
|
<input id="color" class="color-input" type="color" bind:value={color} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="notes">Notes</label>
|
||||||
|
<textarea id="notes" class="textarea" rows="3" bind:value={notes} placeholder="Optional notes…"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="category">Category</label>
|
||||||
|
<select id="category" class="input" bind:value={categoryId}>
|
||||||
|
<option value="">— None —</option>
|
||||||
|
{#each categories as cat (cat.id)}
|
||||||
|
<option value={cat.id}>{cat.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="label">Public</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle"
|
||||||
|
class:on={isPublic}
|
||||||
|
onclick={() => (isPublic = !isPublic)}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isPublic}
|
||||||
|
aria-label="Public"
|
||||||
|
>
|
||||||
|
<span class="thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-row">
|
||||||
|
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="delete-btn" onclick={() => (confirmDelete = true)} disabled={deleting}>
|
||||||
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Tag rules -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">Implied tags</h2>
|
||||||
|
<TagRuleEditor {tagId} {rules} onRulesChange={(r) => (rules = r)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if confirmDelete}
|
||||||
|
<ConfirmDialog
|
||||||
|
message={`Delete tag "${name}"? This cannot be undone.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
danger
|
||||||
|
onConfirm={doDeleteTag}
|
||||||
|
onCancel={() => (confirmDelete = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 10px; min-height: 44px;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
|
border: none; background: none;
|
||||||
|
color: var(--color-text-primary); cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
|
||||||
|
|
||||||
|
.page-title { font-size: 1rem; font-weight: 600; margin: 0; }
|
||||||
|
|
||||||
|
main { flex: 1; overflow-y: auto; padding: 16px 14px calc(60px + 16px); display: flex; flex-direction: column; gap: 24px; }
|
||||||
|
|
||||||
|
.loading-row { display: flex; justify-content: center; padding: 40px; }
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: block; width: 28px; height: 28px;
|
||||||
|
border: 3px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||||
|
border-top-color: var(--color-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
|
.row-fields { display: flex; gap: 10px; align-items: flex-end; }
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
|
||||||
|
.color-field { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.75rem; font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required { color: var(--color-danger); }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%; box-sizing: border-box;
|
||||||
|
height: 36px; padding: 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem; font-family: inherit; outline: none;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--color-accent); }
|
||||||
|
|
||||||
|
.color-input {
|
||||||
|
width: 50px; height: 36px; padding: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%; box-sizing: border-box; padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem; font-family: inherit;
|
||||||
|
resize: vertical; outline: none; min-height: 70px;
|
||||||
|
}
|
||||||
|
.textarea:focus { border-color: var(--color-accent); }
|
||||||
|
|
||||||
|
select.input { cursor: pointer; color-scheme: dark; }
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.toggle-row .label { margin: 0; }
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: relative; width: 44px; height: 26px;
|
||||||
|
border-radius: 13px; border: none;
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
|
cursor: pointer; transition: background-color 0.2s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle.on { background-color: var(--color-accent); }
|
||||||
|
.thumb {
|
||||||
|
position: absolute; top: 3px; left: 3px;
|
||||||
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
background-color: #fff; transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.toggle.on .thumb { transform: translateX(18px); }
|
||||||
|
|
||||||
|
.action-row { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
flex: 1; height: 42px; border-radius: 8px; border: none;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
|
||||||
|
.submit-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
height: 42px; padding: 0 18px; border-radius: 8px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-danger) 50%, transparent);
|
||||||
|
background: none; color: var(--color-danger);
|
||||||
|
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.delete-btn:hover:not(:disabled) { background-color: color-mix(in srgb, var(--color-danger) 12%, transparent); }
|
||||||
|
.delete-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.section { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.75rem; font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error { color: var(--color-danger); font-size: 0.875rem; margin: 0; }
|
||||||
|
</style>
|
||||||
221
frontend/src/routes/tags/new/+page.svelte
Normal file
221
frontend/src/routes/tags/new/+page.svelte
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
import type { Category, CategoryOffsetPage } from '$lib/api/types';
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let notes = $state('');
|
||||||
|
let color = $state('#444455');
|
||||||
|
let categoryId = $state('');
|
||||||
|
let isPublic = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let categories = $state<Category[]>([]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api.get<CategoryOffsetPage>('/categories?limit=200&sort=name&order=asc').then((p) => {
|
||||||
|
categories = p.items ?? [];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!name.trim() || saving) return;
|
||||||
|
saving = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await api.post('/tags', {
|
||||||
|
name: name.trim(),
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
color: color.slice(1), // strip #
|
||||||
|
category_id: categoryId || null,
|
||||||
|
is_public: isPublic,
|
||||||
|
});
|
||||||
|
goto('/tags');
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to create tag';
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>New Tag | Tanabata</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<header class="top-bar">
|
||||||
|
<button class="back-btn" onclick={() => goto('/tags')} aria-label="Back">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 class="page-title">New Tag</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="form" onsubmit={(e) => { e.preventDefault(); void submit(); }}>
|
||||||
|
<div class="row-fields">
|
||||||
|
<div class="field" style="flex: 1">
|
||||||
|
<label class="label" for="name">Name <span class="required">*</span></label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
required
|
||||||
|
placeholder="Tag name"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field color-field">
|
||||||
|
<label class="label" for="color">Color</label>
|
||||||
|
<input id="color" class="color-input" type="color" bind:value={color} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="notes">Notes</label>
|
||||||
|
<textarea id="notes" class="textarea" rows="3" bind:value={notes} placeholder="Optional notes…"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="category">Category</label>
|
||||||
|
<select id="category" class="input" bind:value={categoryId}>
|
||||||
|
<option value="">— None —</option>
|
||||||
|
{#each categories as cat (cat.id)}
|
||||||
|
<option value={cat.id}>{cat.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="label">Public</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle"
|
||||||
|
class:on={isPublic}
|
||||||
|
onclick={() => (isPublic = !isPublic)}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isPublic}
|
||||||
|
aria-label="Public"
|
||||||
|
>
|
||||||
|
<span class="thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="submit-btn" disabled={!name.trim() || saving}>
|
||||||
|
{saving ? 'Creating…' : 'Create tag'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 10px; min-height: 44px;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
|
border: none; background: none;
|
||||||
|
color: var(--color-text-primary); cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-btn:hover { background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); }
|
||||||
|
|
||||||
|
.page-title { font-size: 1rem; font-weight: 600; margin: 0; }
|
||||||
|
|
||||||
|
main { flex: 1; overflow-y: auto; padding: 16px 14px calc(60px + 16px); }
|
||||||
|
|
||||||
|
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
|
.row-fields { display: flex; gap: 10px; align-items: flex-end; }
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
|
||||||
|
.color-field { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.75rem; font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required { color: var(--color-danger); }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%; box-sizing: border-box;
|
||||||
|
height: 36px; padding: 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem; font-family: inherit; outline: none;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--color-accent); }
|
||||||
|
|
||||||
|
.color-input {
|
||||||
|
width: 50px; height: 36px; padding: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%; box-sizing: border-box; padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.875rem; font-family: inherit;
|
||||||
|
resize: vertical; outline: none; min-height: 70px;
|
||||||
|
}
|
||||||
|
.textarea:focus { border-color: var(--color-accent); }
|
||||||
|
|
||||||
|
select.input { cursor: pointer; color-scheme: dark; }
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.toggle-row .label { margin: 0; }
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
position: relative; width: 44px; height: 26px;
|
||||||
|
border-radius: 13px; border: none;
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-elevated));
|
||||||
|
cursor: pointer; transition: background-color 0.2s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle.on { background-color: var(--color-accent); }
|
||||||
|
.thumb {
|
||||||
|
position: absolute; top: 3px; left: 3px;
|
||||||
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
background-color: #fff; transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.toggle.on .thumb { transform: translateX(18px); }
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%; height: 42px; border-radius: 8px; border: none;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
font-size: 0.9rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.submit-btn:hover:not(:disabled) { background-color: var(--color-accent-hover); }
|
||||||
|
.submit-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.error { color: var(--color-danger); font-size: 0.875rem; }
|
||||||
|
</style>
|
||||||
@ -87,6 +87,115 @@ const MOCK_FILES = Array.from({ length: 75 }, (_, i) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const TAG_NAMES = [
|
||||||
|
'nature', 'portrait', 'travel', 'architecture', 'food', 'street', 'macro',
|
||||||
|
'landscape', 'wildlife', 'urban', 'abstract', 'black-and-white', 'night',
|
||||||
|
'golden-hour', 'blue-hour', 'aerial', 'underwater', 'infrared', 'long-exposure',
|
||||||
|
'panorama', 'astrophotography', 'documentary', 'editorial', 'fashion', 'wedding',
|
||||||
|
'newborn', 'maternity', 'family', 'pet', 'sport', 'concert', 'theatre',
|
||||||
|
'interior', 'exterior', 'product', 'still-life', 'automotive', 'aviation',
|
||||||
|
'marine', 'industrial', 'medical', 'scientific', 'satellite', 'drone',
|
||||||
|
'film', 'analog', 'polaroid', 'tilt-shift', 'fisheye', 'telephoto',
|
||||||
|
'wide-angle', 'bokeh', 'silhouette', 'reflection', 'shadow', 'texture',
|
||||||
|
'pattern', 'color', 'minimal', 'surreal', 'conceptual', 'fine-art',
|
||||||
|
'photojournalism', 'war', 'protest', 'people', 'crowd', 'solitude',
|
||||||
|
'children', 'elderly', 'culture', 'tradition', 'festival', 'religion',
|
||||||
|
'asia', 'europe', 'africa', 'americas', 'oceania', 'arctic', 'desert',
|
||||||
|
'forest', 'mountain', 'ocean', 'lake', 'river', 'waterfall', 'cave',
|
||||||
|
'volcano', 'canyon', 'glacier', 'field', 'garden', 'park', 'city',
|
||||||
|
'village', 'ruins', 'bridge', 'road', 'railway', 'harbor', 'airport',
|
||||||
|
'market', 'cafe', 'restaurant', 'bar', 'museum', 'library', 'school',
|
||||||
|
'hospital', 'church', 'mosque', 'temple', 'shrine', 'cemetery', 'stadium',
|
||||||
|
'spring', 'summer', 'autumn', 'winter', 'rain', 'snow', 'fog', 'storm',
|
||||||
|
'sunrise', 'sunset', 'cloudy', 'clear', 'rainbow', 'lightning', 'wind',
|
||||||
|
'cat', 'dog', 'bird', 'horse', 'fish', 'insect', 'reptile', 'mammal',
|
||||||
|
'flower', 'tree', 'grass', 'moss', 'mushroom', 'fruit', 'vegetable',
|
||||||
|
'fire', 'water', 'earth', 'air', 'smoke', 'ice', 'stone', 'wood', 'metal',
|
||||||
|
'glass', 'fabric', 'paper', 'plastic', 'ceramic', 'leather', 'concrete',
|
||||||
|
'red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'pink',
|
||||||
|
'brown', 'white', 'grey', 'dark', 'bright', 'pastel', 'vivid', 'muted',
|
||||||
|
'raw', 'edited', 'hdr', 'composite', 'retouched', 'unedited', 'scanned',
|
||||||
|
'selfie', 'candid', 'posed', 'staged', 'spontaneous', 'planned', 'series',
|
||||||
|
];
|
||||||
|
|
||||||
|
const TAG_COLORS = [
|
||||||
|
'7ECBA1', '9592B5', '4DC7ED', 'E08C5A', 'DB6060',
|
||||||
|
'F5E872', 'A67CB8', '5A9ED4', 'C4A44A', '6DB89E',
|
||||||
|
'E07090', '70B0E0', 'C0A060', '80C080', 'D080B0',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_CATEGORIES = [
|
||||||
|
{ id: '00000000-0000-7000-8002-000000000001', name: 'Style', color: '9592B5', notes: null, created_at: new Date().toISOString() },
|
||||||
|
{ id: '00000000-0000-7000-8002-000000000002', name: 'Subject', color: '4DC7ED', notes: null, created_at: new Date().toISOString() },
|
||||||
|
{ id: '00000000-0000-7000-8002-000000000003', name: 'Location', color: '7ECBA1', notes: null, created_at: new Date().toISOString() },
|
||||||
|
{ id: '00000000-0000-7000-8002-000000000004', name: 'Season', color: 'E08C5A', notes: null, created_at: new Date().toISOString() },
|
||||||
|
{ id: '00000000-0000-7000-8002-000000000005', name: 'Color', color: 'DB6060', notes: null, created_at: new Date().toISOString() },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Assign some tags to categories
|
||||||
|
const CATEGORY_ASSIGNMENTS: Record<string, string> = {};
|
||||||
|
TAG_NAMES.forEach((name, i) => {
|
||||||
|
if (['film', 'analog', 'polaroid', 'bokeh', 'silhouette', 'long-exposure', 'tilt-shift', 'fisheye', 'telephoto', 'wide-angle', 'macro', 'infrared', 'hdr', 'composite'].includes(name))
|
||||||
|
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[0].id; // Style
|
||||||
|
else if (['portrait', 'wildlife', 'people', 'children', 'elderly', 'cat', 'dog', 'bird', 'horse', 'flower', 'tree', 'insect', 'reptile', 'mammal'].includes(name))
|
||||||
|
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[1].id; // Subject
|
||||||
|
else if (['asia', 'europe', 'africa', 'americas', 'oceania', 'arctic', 'desert', 'forest', 'mountain', 'ocean', 'lake', 'river', 'city', 'village'].includes(name))
|
||||||
|
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[2].id; // Location
|
||||||
|
else if (['spring', 'summer', 'autumn', 'winter'].includes(name))
|
||||||
|
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[3].id; // Season
|
||||||
|
else if (['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'pink', 'brown', 'white', 'grey', 'dark', 'bright', 'pastel', 'vivid', 'muted'].includes(name))
|
||||||
|
CATEGORY_ASSIGNMENTS[name] = MOCK_CATEGORIES[4].id; // Color
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCategoryForId(catId: string | null) {
|
||||||
|
if (!catId) return null;
|
||||||
|
return MOCK_CATEGORIES.find((c) => c.id === catId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockTag = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
notes: string | null;
|
||||||
|
category_id: string | null;
|
||||||
|
category_name: string | null;
|
||||||
|
category_color: string | null;
|
||||||
|
is_public: boolean;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTagsArr: MockTag[] = TAG_NAMES.map((name, i) => {
|
||||||
|
const catId = CATEGORY_ASSIGNMENTS[name] ?? null;
|
||||||
|
const cat = getCategoryForId(catId);
|
||||||
|
return {
|
||||||
|
id: `00000000-0000-7000-8001-${String(i + 1).padStart(12, '0')}`,
|
||||||
|
name,
|
||||||
|
color: TAG_COLORS[i % TAG_COLORS.length],
|
||||||
|
notes: null,
|
||||||
|
category_id: catId,
|
||||||
|
category_name: cat?.name ?? null,
|
||||||
|
category_color: cat?.color ?? null,
|
||||||
|
is_public: false,
|
||||||
|
created_at: new Date(Date.now() - i * 3_600_000).toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backwards-compatible reference for existing file-tag lookups
|
||||||
|
const MOCK_TAGS = mockTagsArr;
|
||||||
|
|
||||||
|
// Tag rules: Map<tagId, Map<thenTagId, is_active>>
|
||||||
|
const tagRules = new Map<string, Map<string, boolean>>();
|
||||||
|
|
||||||
|
// Mutable in-memory state for file metadata and tags
|
||||||
|
const fileOverrides = new Map<string, Partial<typeof MOCK_FILES[0]>>();
|
||||||
|
const fileTags = new Map<string, Set<string>>(); // fileId → Set<tagId>
|
||||||
|
|
||||||
|
function getMockFile(id: string) {
|
||||||
|
const base = MOCK_FILES.find((f) => f.id === id);
|
||||||
|
if (!base) return null;
|
||||||
|
return { ...base, ...(fileOverrides.get(id) ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
export function mockApiPlugin(): Plugin {
|
export function mockApiPlugin(): Plugin {
|
||||||
return {
|
return {
|
||||||
name: 'mock-api',
|
name: 'mock-api',
|
||||||
@ -150,28 +259,354 @@ export function mockApiPlugin(): Plugin {
|
|||||||
return res.end(svg);
|
return res.end(svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /files (cursor pagination — page through MOCK_FILES in chunks of 50)
|
// GET /files/{id}/preview (same SVG, just bigger)
|
||||||
|
const previewMatch = path.match(/^\/files\/([^/]+)\/preview$/);
|
||||||
|
if (method === 'GET' && previewMatch) {
|
||||||
|
const id = previewMatch[1];
|
||||||
|
const color = THUMB_COLORS[id.charCodeAt(id.length - 1) % THUMB_COLORS.length];
|
||||||
|
const label = id.slice(-4);
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">
|
||||||
|
<rect width="800" height="600" fill="${color}"/>
|
||||||
|
<text x="400" y="315" text-anchor="middle" font-family="monospace" font-size="48" fill="rgba(0,0,0,0.35)">${label}</text>
|
||||||
|
</svg>`;
|
||||||
|
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Content-Length': Buffer.byteLength(svg) });
|
||||||
|
return res.end(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /files/{id}/tags
|
||||||
|
const fileTagsGetMatch = path.match(/^\/files\/([^/]+)\/tags$/);
|
||||||
|
if (method === 'GET' && fileTagsGetMatch) {
|
||||||
|
const ids = fileTags.get(fileTagsGetMatch[1]) ?? new Set<string>();
|
||||||
|
return json(res, 200, MOCK_TAGS.filter((t) => ids.has(t.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /files/{id}/tags/{tag_id} — add tag
|
||||||
|
const fileTagPutMatch = path.match(/^\/files\/([^/]+)\/tags\/([^/]+)$/);
|
||||||
|
if (method === 'PUT' && fileTagPutMatch) {
|
||||||
|
const [, fid, tid] = fileTagPutMatch;
|
||||||
|
if (!fileTags.has(fid)) fileTags.set(fid, new Set());
|
||||||
|
fileTags.get(fid)!.add(tid);
|
||||||
|
const ids = fileTags.get(fid)!;
|
||||||
|
return json(res, 200, MOCK_TAGS.filter((t) => ids.has(t.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /files/{id}/tags/{tag_id} — remove tag
|
||||||
|
const fileTagDelMatch = path.match(/^\/files\/([^/]+)\/tags\/([^/]+)$/);
|
||||||
|
if (method === 'DELETE' && fileTagDelMatch) {
|
||||||
|
const [, fid, tid] = fileTagDelMatch;
|
||||||
|
fileTags.get(fid)?.delete(tid);
|
||||||
|
return noContent(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /files/{id} — single file
|
||||||
|
const fileGetMatch = path.match(/^\/files\/([^/]+)$/);
|
||||||
|
if (method === 'GET' && fileGetMatch) {
|
||||||
|
const f = getMockFile(fileGetMatch[1]);
|
||||||
|
if (!f) return json(res, 404, { code: 'not_found', message: 'File not found' });
|
||||||
|
return json(res, 200, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /files/{id} — update metadata
|
||||||
|
const filePatchMatch = path.match(/^\/files\/([^/]+)$/);
|
||||||
|
if (method === 'PATCH' && filePatchMatch) {
|
||||||
|
const id = filePatchMatch[1];
|
||||||
|
const base = getMockFile(id);
|
||||||
|
if (!base) return json(res, 404, { code: 'not_found', message: 'File not found' });
|
||||||
|
const body = (await readBody(req)) as Record<string, unknown>;
|
||||||
|
fileOverrides.set(id, { ...(fileOverrides.get(id) ?? {}), ...body });
|
||||||
|
return json(res, 200, getMockFile(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /files/bulk/delete — soft delete (just remove from mock array)
|
||||||
|
if (method === 'POST' && path === '/files/bulk/delete') {
|
||||||
|
const body = (await readBody(req)) as { file_ids?: string[] };
|
||||||
|
const ids = new Set(body.file_ids ?? []);
|
||||||
|
for (let i = MOCK_FILES.length - 1; i >= 0; i--) {
|
||||||
|
if (ids.has(MOCK_FILES[i].id)) MOCK_FILES.splice(i, 1);
|
||||||
|
}
|
||||||
|
return noContent(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /files — upload (mock: drain body, return a new fake file)
|
||||||
|
if (method === 'POST' && path === '/files') {
|
||||||
|
// Drain the multipart body without parsing it
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
req.on('data', () => {});
|
||||||
|
req.on('end', resolve);
|
||||||
|
});
|
||||||
|
const idx = MOCK_FILES.length;
|
||||||
|
const id = `00000000-0000-7000-8000-${String(Date.now()).slice(-12)}`;
|
||||||
|
const ct = req.headers['content-type'] ?? '';
|
||||||
|
// Extract filename from Content-Disposition if present (best-effort)
|
||||||
|
const nameMatch = ct.match(/name="([^"]+)"/);
|
||||||
|
const newFile = {
|
||||||
|
id,
|
||||||
|
original_name: nameMatch ? nameMatch[1] : `upload-${idx + 1}.jpg`,
|
||||||
|
mime_type: 'image/jpeg',
|
||||||
|
mime_extension: 'jpg',
|
||||||
|
content_datetime: new Date().toISOString(),
|
||||||
|
notes: null,
|
||||||
|
metadata: null,
|
||||||
|
exif: {},
|
||||||
|
phash: null,
|
||||||
|
creator_id: 1,
|
||||||
|
creator_name: 'admin',
|
||||||
|
is_public: false,
|
||||||
|
is_deleted: false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
MOCK_FILES.unshift(newFile);
|
||||||
|
return json(res, 201, newFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /files (cursor pagination + anchor support)
|
||||||
if (method === 'GET' && path === '/files') {
|
if (method === 'GET' && path === '/files') {
|
||||||
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
||||||
|
const anchor = qs.get('anchor');
|
||||||
const cursor = qs.get('cursor');
|
const cursor = qs.get('cursor');
|
||||||
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
|
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
|
||||||
|
|
||||||
|
if (anchor) {
|
||||||
|
// Anchor mode: return the anchor file surrounded by neighbors
|
||||||
|
const anchorIdx = MOCK_FILES.findIndex((f) => f.id === anchor);
|
||||||
|
if (anchorIdx < 0) return json(res, 404, { code: 'not_found', message: 'Anchor not found' });
|
||||||
|
const from = Math.max(0, anchorIdx - Math.floor(limit / 2));
|
||||||
|
const slice = MOCK_FILES.slice(from, from + limit);
|
||||||
|
const next_cursor = from + slice.length < MOCK_FILES.length
|
||||||
|
? Buffer.from(String(from + slice.length)).toString('base64') : null;
|
||||||
|
const prev_cursor = from > 0
|
||||||
|
? Buffer.from(String(from)).toString('base64') : null;
|
||||||
|
return json(res, 200, { items: slice, next_cursor, prev_cursor });
|
||||||
|
}
|
||||||
|
|
||||||
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
|
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
|
||||||
const slice = MOCK_FILES.slice(offset, offset + limit);
|
const slice = MOCK_FILES.slice(offset, offset + limit);
|
||||||
const nextOffset = offset + slice.length;
|
const nextOffset = offset + slice.length;
|
||||||
const next_cursor = nextOffset < MOCK_FILES.length
|
const next_cursor = nextOffset < MOCK_FILES.length
|
||||||
? Buffer.from(String(nextOffset)).toString('base64')
|
? Buffer.from(String(nextOffset)).toString('base64') : null;
|
||||||
: null;
|
|
||||||
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
|
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /tags/{id}/rules
|
||||||
|
const tagRulesGetMatch = path.match(/^\/tags\/([^/]+)\/rules$/);
|
||||||
|
if (method === 'GET' && tagRulesGetMatch) {
|
||||||
|
const tid = tagRulesGetMatch[1];
|
||||||
|
const ruleMap = tagRules.get(tid) ?? new Map<string, boolean>();
|
||||||
|
const items = [...ruleMap.entries()].map(([thenId, isActive]) => {
|
||||||
|
const t = MOCK_TAGS.find((x) => x.id === thenId);
|
||||||
|
return { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive };
|
||||||
|
});
|
||||||
|
return json(res, 200, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /tags/{id}/rules
|
||||||
|
const tagRulesPostMatch = path.match(/^\/tags\/([^/]+)\/rules$/);
|
||||||
|
if (method === 'POST' && tagRulesPostMatch) {
|
||||||
|
const tid = tagRulesPostMatch[1];
|
||||||
|
const body = (await readBody(req)) as Record<string, unknown>;
|
||||||
|
const thenId = body.then_tag_id as string;
|
||||||
|
const isActive = body.is_active !== false;
|
||||||
|
if (!tagRules.has(tid)) tagRules.set(tid, new Map());
|
||||||
|
tagRules.get(tid)!.set(thenId, isActive);
|
||||||
|
const t = MOCK_TAGS.find((x) => x.id === thenId);
|
||||||
|
return json(res, 201, { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /tags/{id}/rules/{then_id} — activate / deactivate
|
||||||
|
const tagRulesPatchMatch = path.match(/^\/tags\/([^/]+)\/rules\/([^/]+)$/);
|
||||||
|
if (method === 'PATCH' && tagRulesPatchMatch) {
|
||||||
|
const [, tid, thenId] = tagRulesPatchMatch;
|
||||||
|
const body = (await readBody(req)) as Record<string, unknown>;
|
||||||
|
const isActive = body.is_active as boolean;
|
||||||
|
const ruleMap = tagRules.get(tid);
|
||||||
|
if (!ruleMap?.has(thenId)) return json(res, 404, { code: 'not_found', message: 'Rule not found' });
|
||||||
|
ruleMap.set(thenId, isActive);
|
||||||
|
const t = MOCK_TAGS.find((x) => x.id === thenId);
|
||||||
|
return json(res, 200, { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /tags/{id}/rules/{then_id}
|
||||||
|
const tagRulesDelMatch = path.match(/^\/tags\/([^/]+)\/rules\/([^/]+)$/);
|
||||||
|
if (method === 'DELETE' && tagRulesDelMatch) {
|
||||||
|
const [, tid, thenId] = tagRulesDelMatch;
|
||||||
|
tagRules.get(tid)?.delete(thenId);
|
||||||
|
return noContent(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /tags/{id}
|
||||||
|
const tagGetMatch = path.match(/^\/tags\/([^/]+)$/);
|
||||||
|
if (method === 'GET' && tagGetMatch) {
|
||||||
|
const t = MOCK_TAGS.find((x) => x.id === tagGetMatch[1]);
|
||||||
|
if (!t) return json(res, 404, { code: 'not_found', message: 'Tag not found' });
|
||||||
|
return json(res, 200, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /tags/{id}
|
||||||
|
const tagPatchMatch = path.match(/^\/tags\/([^/]+)$/);
|
||||||
|
if (method === 'PATCH' && tagPatchMatch) {
|
||||||
|
const idx = MOCK_TAGS.findIndex((x) => x.id === tagPatchMatch[1]);
|
||||||
|
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'Tag not found' });
|
||||||
|
const body = (await readBody(req)) as Partial<MockTag>;
|
||||||
|
const catId = body.category_id ?? MOCK_TAGS[idx].category_id;
|
||||||
|
const cat = getCategoryForId(catId);
|
||||||
|
Object.assign(MOCK_TAGS[idx], {
|
||||||
|
...body,
|
||||||
|
category_name: cat?.name ?? null,
|
||||||
|
category_color: cat?.color ?? null,
|
||||||
|
});
|
||||||
|
return json(res, 200, MOCK_TAGS[idx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /tags/{id}
|
||||||
|
const tagDelMatch = path.match(/^\/tags\/([^/]+)$/);
|
||||||
|
if (method === 'DELETE' && tagDelMatch) {
|
||||||
|
const idx = MOCK_TAGS.findIndex((x) => x.id === tagDelMatch[1]);
|
||||||
|
if (idx >= 0) MOCK_TAGS.splice(idx, 1);
|
||||||
|
return noContent(res);
|
||||||
|
}
|
||||||
|
|
||||||
// GET /tags
|
// GET /tags
|
||||||
if (method === 'GET' && path === '/tags') {
|
if (method === 'GET' && path === '/tags') {
|
||||||
return json(res, 200, { items: [], total: 0, offset: 0, limit: 50 });
|
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
||||||
|
const search = qs.get('search')?.toLowerCase() ?? '';
|
||||||
|
const sort = qs.get('sort') ?? 'name';
|
||||||
|
const order = qs.get('order') ?? 'asc';
|
||||||
|
const limit = Math.min(Number(qs.get('limit') ?? 100), 500);
|
||||||
|
const offset = Number(qs.get('offset') ?? 0);
|
||||||
|
|
||||||
|
let filtered = search
|
||||||
|
? MOCK_TAGS.filter((t) => t.name.toLowerCase().includes(search))
|
||||||
|
: [...MOCK_TAGS];
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let av: string, bv: string;
|
||||||
|
if (sort === 'color') { av = a.color; bv = b.color; }
|
||||||
|
else if (sort === 'category_name') { av = a.category_name ?? ''; bv = b.category_name ?? ''; }
|
||||||
|
else if (sort === 'created') { av = a.created_at; bv = b.created_at; }
|
||||||
|
else { av = a.name; bv = b.name; }
|
||||||
|
const cmp = av.localeCompare(bv);
|
||||||
|
return order === 'desc' ? -cmp : cmp;
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = filtered.slice(offset, offset + limit);
|
||||||
|
return json(res, 200, { items, total: filtered.length, offset, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /tags
|
||||||
|
if (method === 'POST' && path === '/tags') {
|
||||||
|
const body = (await readBody(req)) as Partial<MockTag>;
|
||||||
|
const catId = body.category_id ?? null;
|
||||||
|
const cat = getCategoryForId(catId);
|
||||||
|
const newTag: MockTag = {
|
||||||
|
id: `00000000-0000-7000-8001-${String(Date.now()).slice(-12)}`,
|
||||||
|
name: body.name ?? 'Unnamed',
|
||||||
|
color: body.color ?? '444455',
|
||||||
|
notes: body.notes ?? null,
|
||||||
|
category_id: catId,
|
||||||
|
category_name: cat?.name ?? null,
|
||||||
|
category_color: cat?.color ?? null,
|
||||||
|
is_public: body.is_public ?? false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
MOCK_TAGS.unshift(newTag);
|
||||||
|
return json(res, 201, newTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /categories/{id}/tags
|
||||||
|
const catTagsMatch = path.match(/^\/categories\/([^/]+)\/tags$/);
|
||||||
|
if (method === 'GET' && catTagsMatch) {
|
||||||
|
const catId = catTagsMatch[1];
|
||||||
|
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
||||||
|
const limit = Math.min(Number(qs.get('limit') ?? 100), 500);
|
||||||
|
const offset = Number(qs.get('offset') ?? 0);
|
||||||
|
const all = MOCK_TAGS.filter((t) => t.category_id === catId);
|
||||||
|
all.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const items = all.slice(offset, offset + limit);
|
||||||
|
return json(res, 200, { items, total: all.length, offset, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /categories/{id}
|
||||||
|
const catGetMatch = path.match(/^\/categories\/([^/]+)$/);
|
||||||
|
if (method === 'GET' && catGetMatch) {
|
||||||
|
const cat = MOCK_CATEGORIES.find((c) => c.id === catGetMatch[1]);
|
||||||
|
if (!cat) return json(res, 404, { code: 'not_found', message: 'Category not found' });
|
||||||
|
return json(res, 200, cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /categories/{id}
|
||||||
|
const catPatchMatch = path.match(/^\/categories\/([^/]+)$/);
|
||||||
|
if (method === 'PATCH' && catPatchMatch) {
|
||||||
|
const idx = MOCK_CATEGORIES.findIndex((c) => c.id === catPatchMatch[1]);
|
||||||
|
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'Category not found' });
|
||||||
|
const body = (await readBody(req)) as Partial<typeof MOCK_CATEGORIES[0]>;
|
||||||
|
Object.assign(MOCK_CATEGORIES[idx], body);
|
||||||
|
// Sync category_name/color on affected tags
|
||||||
|
const cat = MOCK_CATEGORIES[idx];
|
||||||
|
for (const t of MOCK_TAGS) {
|
||||||
|
if (t.category_id === cat.id) {
|
||||||
|
t.category_name = cat.name;
|
||||||
|
t.category_color = cat.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json(res, 200, MOCK_CATEGORIES[idx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /categories/{id}
|
||||||
|
const catDelMatch = path.match(/^\/categories\/([^/]+)$/);
|
||||||
|
if (method === 'DELETE' && catDelMatch) {
|
||||||
|
const idx = MOCK_CATEGORIES.findIndex((c) => c.id === catDelMatch[1]);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const catId = MOCK_CATEGORIES[idx].id;
|
||||||
|
MOCK_CATEGORIES.splice(idx, 1);
|
||||||
|
for (const t of MOCK_TAGS) {
|
||||||
|
if (t.category_id === catId) {
|
||||||
|
t.category_id = null;
|
||||||
|
t.category_name = null;
|
||||||
|
t.category_color = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return noContent(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /categories
|
// GET /categories
|
||||||
if (method === 'GET' && path === '/categories') {
|
if (method === 'GET' && path === '/categories') {
|
||||||
return json(res, 200, { items: [], total: 0, offset: 0, limit: 50 });
|
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
||||||
|
const search = qs.get('search')?.toLowerCase() ?? '';
|
||||||
|
const sort = qs.get('sort') ?? 'name';
|
||||||
|
const order = qs.get('order') ?? 'asc';
|
||||||
|
const limit = Math.min(Number(qs.get('limit') ?? 50), 500);
|
||||||
|
const offset = Number(qs.get('offset') ?? 0);
|
||||||
|
|
||||||
|
let filtered = search
|
||||||
|
? MOCK_CATEGORIES.filter((c) => c.name.toLowerCase().includes(search))
|
||||||
|
: [...MOCK_CATEGORIES];
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let av: string, bv: string;
|
||||||
|
if (sort === 'color') { av = a.color; bv = b.color; }
|
||||||
|
else if (sort === 'created') { av = a.created_at; bv = b.created_at; }
|
||||||
|
else { av = a.name; bv = b.name; }
|
||||||
|
const cmp = av.localeCompare(bv);
|
||||||
|
return order === 'desc' ? -cmp : cmp;
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = filtered.slice(offset, offset + limit);
|
||||||
|
return json(res, 200, { items, total: filtered.length, offset, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /categories
|
||||||
|
if (method === 'POST' && path === '/categories') {
|
||||||
|
const body = (await readBody(req)) as Partial<typeof MOCK_CATEGORIES[0]>;
|
||||||
|
const newCat = {
|
||||||
|
id: `00000000-0000-7000-8002-${String(Date.now()).slice(-12)}`,
|
||||||
|
name: body.name ?? 'Unnamed',
|
||||||
|
color: body.color ?? '9592B5',
|
||||||
|
notes: body.notes ?? null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
MOCK_CATEGORIES.unshift(newCat);
|
||||||
|
return json(res, 201, newCat);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /pools
|
// GET /pools
|
||||||
|
|||||||
31
openapi.yaml
31
openapi.yaml
@ -825,6 +825,37 @@ paths:
|
|||||||
$ref: '#/components/schemas/TagRule'
|
$ref: '#/components/schemas/TagRule'
|
||||||
|
|
||||||
/tags/{tag_id}/rules/{then_tag_id}:
|
/tags/{tag_id}/rules/{then_tag_id}:
|
||||||
|
patch:
|
||||||
|
tags: [Tags]
|
||||||
|
summary: Update a tag rule (activate / deactivate)
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/tag_id'
|
||||||
|
- name: then_tag_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [is_active]
|
||||||
|
properties:
|
||||||
|
is_active:
|
||||||
|
type: boolean
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rule updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TagRule'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
delete:
|
delete:
|
||||||
tags: [Tags]
|
tags: [Tags]
|
||||||
summary: Remove a tag rule
|
summary: Remove a tag rule
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user