Compare commits
10 Commits
9b1aa40522
...
135c71ae4d
| Author | SHA1 | Date | |
|---|---|---|---|
| 135c71ae4d | |||
| d38e54e307 | |||
| c6e91c2eaf | |||
| d6e9223f61 | |||
| 004ff0b45e | |||
| 6e052efebf | |||
| 70cbb45b01 | |||
| 012c6f9c48 | |||
| 8cfcd39ab6 | |||
| 6da25dc696 |
@ -574,19 +574,46 @@ JOIN data.tags t ON t.id = ins.then_tag_id`
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *TagRuleRepo) SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active bool) error {
|
func (r *TagRuleRepo) SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active, applyToExisting bool) error {
|
||||||
const query = `
|
const updateQuery = `
|
||||||
UPDATE data.tag_rules SET is_active = $3
|
UPDATE data.tag_rules SET is_active = $3
|
||||||
WHERE when_tag_id = $1 AND then_tag_id = $2`
|
WHERE when_tag_id = $1 AND then_tag_id = $2`
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
q := connOrTx(ctx, r.pool)
|
||||||
ct, err := q.Exec(ctx, query, whenTagID, thenTagID, active)
|
ct, err := q.Exec(ctx, updateQuery, whenTagID, thenTagID, active)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("TagRuleRepo.SetActive: %w", err)
|
return fmt.Errorf("TagRuleRepo.SetActive: %w", err)
|
||||||
}
|
}
|
||||||
if ct.RowsAffected() == 0 {
|
if ct.RowsAffected() == 0 {
|
||||||
return domain.ErrNotFound
|
return domain.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !active || !applyToExisting {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retroactively apply the full transitive expansion of thenTagID to all
|
||||||
|
// files that already carry whenTagID. The recursive CTE walks active rules
|
||||||
|
// starting from thenTagID (mirrors the Go expandTagSet BFS).
|
||||||
|
const retroQuery = `
|
||||||
|
WITH RECURSIVE expansion(tag_id) AS (
|
||||||
|
SELECT $2::uuid
|
||||||
|
UNION
|
||||||
|
SELECT r.then_tag_id
|
||||||
|
FROM data.tag_rules r
|
||||||
|
JOIN expansion e ON r.when_tag_id = e.tag_id
|
||||||
|
WHERE r.is_active = true
|
||||||
|
)
|
||||||
|
INSERT INTO data.file_tag (file_id, tag_id)
|
||||||
|
SELECT ft.file_id, e.tag_id
|
||||||
|
FROM data.file_tag ft
|
||||||
|
CROSS JOIN expansion e
|
||||||
|
WHERE ft.tag_id = $1
|
||||||
|
ON CONFLICT DO NOTHING`
|
||||||
|
|
||||||
|
if _, err := q.Exec(ctx, retroQuery, whenTagID, thenTagID); err != nil {
|
||||||
|
return fmt.Errorf("TagRuleRepo.SetActive retroactive apply: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -371,14 +371,20 @@ func (h *TagHandler) PatchRule(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
IsActive *bool `json:"is_active"`
|
IsActive *bool `json:"is_active"`
|
||||||
|
ApplyToExisting *bool `json:"apply_to_existing"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil || body.IsActive == nil {
|
if err := c.ShouldBindJSON(&body); err != nil || body.IsActive == nil {
|
||||||
respondError(c, domain.ErrValidation)
|
respondError(c, domain.ErrValidation)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rule, err := h.tagSvc.SetRuleActive(c.Request.Context(), whenTagID, thenTagID, *body.IsActive)
|
applyToExisting := false
|
||||||
|
if body.ApplyToExisting != nil {
|
||||||
|
applyToExisting = *body.ApplyToExisting
|
||||||
|
}
|
||||||
|
|
||||||
|
rule, err := h.tagSvc.SetRuleActive(c.Request.Context(), whenTagID, thenTagID, *body.IsActive, applyToExisting)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -552,6 +552,90 @@ func TestPoolReorder(t *testing.T) {
|
|||||||
assert.Equal(t, id1, items2[1].(map[string]any)["id"])
|
assert.Equal(t, id1, items2[1].(map[string]any)["id"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTagRuleActivateApplyToExisting verifies that activating a rule with
|
||||||
|
// apply_to_existing=true retroactively tags existing files, including
|
||||||
|
// transitive rules (A→B active+apply, B→C already active → file gets A,B,C).
|
||||||
|
func TestTagRuleActivateApplyToExisting(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
h := setupSuite(t)
|
||||||
|
tok := h.login("admin", "admin")
|
||||||
|
|
||||||
|
// Create three tags: A, B, C.
|
||||||
|
mkTag := func(name string) string {
|
||||||
|
resp := h.doJSON("POST", "/tags", map[string]any{"name": name}, tok)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
|
var obj map[string]any
|
||||||
|
resp.decode(t, &obj)
|
||||||
|
return obj["id"].(string)
|
||||||
|
}
|
||||||
|
tagA := mkTag("animal")
|
||||||
|
tagB := mkTag("living-thing")
|
||||||
|
tagC := mkTag("organism")
|
||||||
|
|
||||||
|
// Rule A→B: created inactive so it does NOT fire on assign.
|
||||||
|
resp := h.doJSON("POST", "/tags/"+tagA+"/rules", map[string]any{
|
||||||
|
"then_tag_id": tagB,
|
||||||
|
"is_active": false,
|
||||||
|
}, tok)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
|
// Rule B→C: active, so it fires transitively when B is applied.
|
||||||
|
resp = h.doJSON("POST", "/tags/"+tagB+"/rules", map[string]any{
|
||||||
|
"then_tag_id": tagC,
|
||||||
|
"is_active": true,
|
||||||
|
}, tok)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
|
// Upload a file and assign only tag A. A→B is inactive so only A is set.
|
||||||
|
file := h.uploadJPEG(tok, "cat.jpg")
|
||||||
|
fileID := file["id"].(string)
|
||||||
|
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
|
||||||
|
"tag_ids": []string{tagA},
|
||||||
|
}, tok)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
|
tagNames := func() []string {
|
||||||
|
r := h.doJSON("GET", "/files/"+fileID+"/tags", nil, tok)
|
||||||
|
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||||
|
var items []any
|
||||||
|
r.decode(t, &items)
|
||||||
|
names := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
names = append(names, it.(map[string]any)["name"].(string))
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before activation: file should only have tag A.
|
||||||
|
assert.ElementsMatch(t, []string{"animal"}, tagNames())
|
||||||
|
|
||||||
|
// Activate A→B WITHOUT apply_to_existing — existing file must not change.
|
||||||
|
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
|
||||||
|
"is_active": true,
|
||||||
|
"apply_to_existing": false,
|
||||||
|
}, tok)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
|
assert.ElementsMatch(t, []string{"animal"}, tagNames(), "file should be unchanged when apply_to_existing=false")
|
||||||
|
|
||||||
|
// Deactivate again so we can test the positive case cleanly.
|
||||||
|
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
|
||||||
|
"is_active": false,
|
||||||
|
}, tok)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
|
// Activate A→B WITH apply_to_existing=true.
|
||||||
|
// Expectation: file gets B directly, and C transitively via the active B→C rule.
|
||||||
|
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
|
||||||
|
"is_active": true,
|
||||||
|
"apply_to_existing": true,
|
||||||
|
}, tok)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
|
assert.ElementsMatch(t, []string{"animal", "living-thing", "organism"}, tagNames())
|
||||||
|
}
|
||||||
|
|
||||||
// TestTagAutoRule verifies that adding a tag automatically applies then_tags.
|
// TestTagAutoRule verifies that adding a tag automatically applies then_tags.
|
||||||
func TestTagAutoRule(t *testing.T) {
|
func TestTagAutoRule(t *testing.T) {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
|
|||||||
@ -83,8 +83,10 @@ type TagRuleRepo interface {
|
|||||||
// ListByTag returns all rules where WhenTagID == tagID.
|
// ListByTag returns all rules where WhenTagID == tagID.
|
||||||
ListByTag(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error)
|
ListByTag(ctx context.Context, tagID uuid.UUID) ([]domain.TagRule, error)
|
||||||
Create(ctx context.Context, r domain.TagRule) (*domain.TagRule, error)
|
Create(ctx context.Context, r domain.TagRule) (*domain.TagRule, error)
|
||||||
// SetActive toggles a rule's is_active flag.
|
// SetActive toggles a rule's is_active flag. When active and applyToExisting
|
||||||
SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active bool) error
|
// are both true, the full transitive expansion of thenTagID is retroactively
|
||||||
|
// applied to all files that already carry whenTagID.
|
||||||
|
SetActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active, applyToExisting bool) error
|
||||||
Delete(ctx context.Context, whenTagID, thenTagID uuid.UUID) error
|
Delete(ctx context.Context, whenTagID, thenTagID uuid.UUID) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -192,8 +192,10 @@ func (s *TagService) CreateRule(ctx context.Context, whenTagID, thenTagID uuid.U
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetRuleActive toggles a rule's is_active flag and returns the updated rule.
|
// 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) {
|
// When active and applyToExisting are both true, the full transitive expansion
|
||||||
if err := s.rules.SetActive(ctx, whenTagID, thenTagID, active); err != nil {
|
// of thenTagID is retroactively applied to files already carrying whenTagID.
|
||||||
|
func (s *TagService) SetRuleActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active, applyToExisting bool) (*domain.TagRule, error) {
|
||||||
|
if err := s.rules.SetActive(ctx, whenTagID, thenTagID, active, applyToExisting); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rules, err := s.rules.ListByTag(ctx, whenTagID)
|
rules, err := s.rules.ListByTag(ctx, whenTagID)
|
||||||
|
|||||||
@ -12,6 +12,8 @@
|
|||||||
--color-info: #4DC7ED;
|
--color-info: #4DC7ED;
|
||||||
--color-warning: #F5E872;
|
--color-warning: #F5E872;
|
||||||
--color-tag-default: #444455;
|
--color-tag-default: #444455;
|
||||||
|
--color-nav-bg: rgba(0, 0, 0, 0.45);
|
||||||
|
--color-nav-active: rgba(52, 50, 73, 0.72);
|
||||||
|
|
||||||
--font-sans: 'Epilogue', sans-serif;
|
--font-sans: 'Epilogue', sans-serif;
|
||||||
}
|
}
|
||||||
@ -25,6 +27,8 @@
|
|||||||
--color-text-primary: #111118;
|
--color-text-primary: #111118;
|
||||||
--color-text-muted: #555566;
|
--color-text-muted: #555566;
|
||||||
--color-tag-default: #ccccdd;
|
--color-tag-default: #ccccdd;
|
||||||
|
--color-nav-bg: rgba(240, 240, 245, 0.85);
|
||||||
|
--color-nav-active: rgba(90, 87, 143, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|||||||
@ -3,6 +3,28 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#312F45" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Tanabata" />
|
||||||
|
<meta name="msapplication-TileColor" content="#312F45" />
|
||||||
|
<meta name="msapplication-TileImage" content="/images/ms-icon-144x144.png" />
|
||||||
|
<meta name="msapplication-config" content="/browserconfig.xml" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="57x57" href="/images/apple-icon-57x57.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="60x60" href="/images/apple-icon-60x60.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="72x72" href="/images/apple-icon-72x72.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="76x76" href="/images/apple-icon-76x76.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="114x114" href="/images/apple-icon-114x114.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="/images/apple-icon-120x120.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="144x144" href="/images/apple-icon-144x144.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="/images/apple-icon-152x152.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-icon-180x180.png" />
|
||||||
<link rel="preload" href="/fonts/Epilogue-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin="anonymous" />
|
<link rel="preload" href="/fonts/Epilogue-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin="anonymous" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
onOrderToggle: () => void;
|
onOrderToggle: () => void;
|
||||||
onFilterToggle: () => void;
|
onFilterToggle: () => void;
|
||||||
onUpload?: () => void;
|
onUpload?: () => void;
|
||||||
|
onTrash?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@ -22,6 +23,7 @@
|
|||||||
onOrderToggle,
|
onOrderToggle,
|
||||||
onFilterToggle,
|
onFilterToggle,
|
||||||
onUpload,
|
onUpload,
|
||||||
|
onTrash,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -43,6 +45,14 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if onTrash}
|
||||||
|
<button class="icon-btn trash-btn" onclick={onTrash} title="Trash">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||||
|
<path d="M2 4h11M5 4V2.5h5V4M5.5 7v4.5M9.5 7v4.5M3 4l.8 9h7.4l.8-9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<select
|
<select
|
||||||
class="sort-select"
|
class="sort-select"
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { api, ApiError } from '$lib/api/client';
|
import { api, ApiError } from '$lib/api/client';
|
||||||
import type { Tag, TagOffsetPage, TagRule } from '$lib/api/types';
|
import type { Tag, TagOffsetPage, TagRule } from '$lib/api/types';
|
||||||
import TagBadge from './TagBadge.svelte';
|
import TagBadge from './TagBadge.svelte';
|
||||||
|
import { appSettings } from '$lib/stores/appSettings';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tagId: string;
|
tagId: string;
|
||||||
@ -62,10 +63,11 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
error = '';
|
error = '';
|
||||||
const thenTagId = rule.then_tag_id!;
|
const thenTagId = rule.then_tag_id!;
|
||||||
|
const activating = !rule.is_active;
|
||||||
try {
|
try {
|
||||||
const updated = await api.patch<TagRule>(`/tags/${tagId}/rules/${thenTagId}`, {
|
const body: Record<string, unknown> = { is_active: activating };
|
||||||
is_active: !rule.is_active,
|
if (activating) body.apply_to_existing = $appSettings.tagRuleApplyToExisting;
|
||||||
});
|
const updated = await api.patch<TagRule>(`/tags/${tagId}/rules/${thenTagId}`, body);
|
||||||
onRulesChange(rules.map((r) => r.then_tag_id === thenTagId ? updated : r));
|
onRulesChange(rules.map((r) => r.then_tag_id === thenTagId ? updated : r));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : 'Failed to update rule';
|
error = e instanceof ApiError ? e.message : 'Failed to update rule';
|
||||||
|
|||||||
28
frontend/src/lib/stores/appSettings.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
fileLoadLimit: number;
|
||||||
|
tagRuleApplyToExisting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: AppSettings = {
|
||||||
|
fileLoadLimit: 100,
|
||||||
|
tagRuleApplyToExisting: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function load(): AppSettings {
|
||||||
|
if (!browser) return { ...DEFAULTS };
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(localStorage.getItem('app-settings') ?? 'null');
|
||||||
|
return stored ? { ...DEFAULTS, ...stored } : { ...DEFAULTS };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULTS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appSettings = writable<AppSettings>(load());
|
||||||
|
|
||||||
|
appSettings.subscribe((v) => {
|
||||||
|
if (browser) localStorage.setItem('app-settings', JSON.stringify(v));
|
||||||
|
});
|
||||||
14
frontend/src/lib/utils/pwa.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Unregisters all service workers and clears all caches, then reloads.
|
||||||
|
* Use this when the app feels stale or to force a clean re-fetch of all assets.
|
||||||
|
*/
|
||||||
|
export async function resetPwa(): Promise<void> {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
await Promise.all(registrations.map((r) => r.unregister()));
|
||||||
|
}
|
||||||
|
if ('caches' in window) {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,11 +34,12 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
const isLogin = $derived($page.url.pathname === '/login');
|
const isLogin = $derived($page.url.pathname === '/login');
|
||||||
|
const isAdmin = $derived($page.url.pathname.startsWith('/admin'));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
{#if !isLogin}
|
{#if !isLogin && !isAdmin}
|
||||||
<footer>
|
<footer>
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
{@const active = $page.url.pathname.startsWith(item.match)}
|
{@const active = $page.url.pathname.startsWith(item.match)}
|
||||||
@ -94,7 +95,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: rgba(0, 0, 0, 0.45);
|
background-color: var(--color-nav-bg);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
-webkit-backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@ -114,7 +115,7 @@
|
|||||||
|
|
||||||
.nav:hover,
|
.nav:hover,
|
||||||
.nav.curr {
|
.nav.curr {
|
||||||
background-color: #343249;
|
background-color: var(--color-nav-active);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
115
frontend/src/routes/admin/+layout.svelte
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ href: '/admin/users', label: 'Users' },
|
||||||
|
{ href: '/admin/audit', label: 'Audit log' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="admin-shell">
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<button class="back-btn" onclick={() => goto('/files')} aria-label="Back to files">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M10 3L5 8L10 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="admin-title">Admin</span>
|
||||||
|
<div class="tabs">
|
||||||
|
{#each tabs as tab}
|
||||||
|
<a
|
||||||
|
href={tab.href}
|
||||||
|
class="tab"
|
||||||
|
class:active={$page.url.pathname.startsWith(tab.href)}
|
||||||
|
>{tab.label}</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="admin-content">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.admin-shell {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
frontend/src/routes/admin/+layout.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { authStore } from '$lib/stores/auth';
|
||||||
|
|
||||||
|
export const load = () => {
|
||||||
|
if (!browser) return;
|
||||||
|
const { user } = get(authStore);
|
||||||
|
if (!user?.isAdmin) {
|
||||||
|
redirect(307, '/files');
|
||||||
|
}
|
||||||
|
};
|
||||||
513
frontend/src/routes/admin/audit/+page.svelte
Normal file
@ -0,0 +1,513 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
import type { AuditEntry, AuditOffsetPage, User, UserOffsetPage } from '$lib/api/types';
|
||||||
|
|
||||||
|
const LIMIT = 50;
|
||||||
|
const OBJECT_TYPES = ['file', 'tag', 'category', 'pool'];
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
// Auth
|
||||||
|
user_login: 'User logged in',
|
||||||
|
user_logout: 'User logged out',
|
||||||
|
// Files
|
||||||
|
file_create: 'File uploaded',
|
||||||
|
file_edit: 'File edited',
|
||||||
|
file_delete: 'File deleted',
|
||||||
|
file_restore: 'File restored',
|
||||||
|
file_permanent_delete: 'File permanently deleted',
|
||||||
|
file_replace: 'File replaced',
|
||||||
|
// Tags
|
||||||
|
tag_create: 'Tag created',
|
||||||
|
tag_edit: 'Tag edited',
|
||||||
|
tag_delete: 'Tag deleted',
|
||||||
|
// Categories
|
||||||
|
category_create: 'Category created',
|
||||||
|
category_edit: 'Category edited',
|
||||||
|
category_delete: 'Category deleted',
|
||||||
|
// Pools
|
||||||
|
pool_create: 'Pool created',
|
||||||
|
pool_edit: 'Pool edited',
|
||||||
|
pool_delete: 'Pool deleted',
|
||||||
|
// Relations
|
||||||
|
file_tag_add: 'Tag added to file',
|
||||||
|
file_tag_remove: 'Tag removed from file',
|
||||||
|
file_pool_add: 'File added to pool',
|
||||||
|
file_pool_remove: 'File removed from pool',
|
||||||
|
// ACL
|
||||||
|
acl_change: 'ACL changed',
|
||||||
|
// Admin
|
||||||
|
user_create: 'User created',
|
||||||
|
user_delete: 'User deleted',
|
||||||
|
user_block: 'User blocked',
|
||||||
|
user_unblock: 'User unblocked',
|
||||||
|
user_role_change: 'User role changed',
|
||||||
|
// Sessions
|
||||||
|
session_terminate: 'Session terminated',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Filters ----
|
||||||
|
let filterUserId = $state('');
|
||||||
|
let filterAction = $state('');
|
||||||
|
let filterObjectType = $state('');
|
||||||
|
let filterObjectId = $state('');
|
||||||
|
let filterFrom = $state('');
|
||||||
|
let filterTo = $state('');
|
||||||
|
|
||||||
|
// ---- Data ----
|
||||||
|
let entries = $state<AuditEntry[]>([]);
|
||||||
|
let total = $state(0);
|
||||||
|
let page = $state(0); // 0-based
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let initialLoaded = $state(false);
|
||||||
|
|
||||||
|
let totalPages = $derived(Math.max(1, Math.ceil(total / LIMIT)));
|
||||||
|
|
||||||
|
// ---- Users for filter dropdown ----
|
||||||
|
let allUsers = $state<User[]>([]);
|
||||||
|
$effect(() => {
|
||||||
|
api.get<UserOffsetPage>('/users?limit=200').then((r) => { allUsers = r.items ?? []; }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unknown action types not in ACTION_LABELS (server may add new ones)
|
||||||
|
let knownActions = $derived([...new Set(entries.map((e) => e.action).filter(Boolean))].sort() as string[]);
|
||||||
|
|
||||||
|
// ---- Reset on filter change ----
|
||||||
|
let filterKey = $derived(`${filterUserId}|${filterAction}|${filterObjectType}|${filterObjectId}|${filterFrom}|${filterTo}`);
|
||||||
|
let prevFilterKey = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (filterKey !== prevFilterKey) {
|
||||||
|
prevFilterKey = filterKey;
|
||||||
|
page = 0;
|
||||||
|
initialLoaded = false;
|
||||||
|
error = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$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(page * LIMIT) });
|
||||||
|
if (filterUserId) params.set('user_id', filterUserId);
|
||||||
|
if (filterAction) params.set('action', filterAction);
|
||||||
|
if (filterObjectType) params.set('object_type', filterObjectType);
|
||||||
|
if (filterObjectId.trim()) params.set('object_id', filterObjectId.trim());
|
||||||
|
if (filterFrom) params.set('from', new Date(filterFrom).toISOString());
|
||||||
|
if (filterTo) params.set('to', new Date(filterTo).toISOString());
|
||||||
|
|
||||||
|
const res = await api.get<AuditOffsetPage>(`/audit?${params}`);
|
||||||
|
entries = res.items ?? [];
|
||||||
|
total = res.total ?? entries.length;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to load audit log';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
initialLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToPage(p: number) {
|
||||||
|
if (p < 0 || p >= totalPages || p === page) return;
|
||||||
|
page = p;
|
||||||
|
initialLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTs(iso: string | undefined | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: string | undefined | null): string {
|
||||||
|
if (!action) return '—';
|
||||||
|
return ACTION_LABELS[action] ?? action.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(id: string | undefined | null): string {
|
||||||
|
if (!id) return '—';
|
||||||
|
return id.slice(-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
filterUserId = '';
|
||||||
|
filterAction = '';
|
||||||
|
filterObjectType = '';
|
||||||
|
filterObjectId = '';
|
||||||
|
filterFrom = '';
|
||||||
|
filterTo = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let filtersActive = $derived(
|
||||||
|
!!(filterUserId || filterAction || filterObjectType || filterObjectId || filterFrom || filterTo)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Audit Log — Admin | Tanabata</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="filters">
|
||||||
|
<div class="filters-row">
|
||||||
|
<select class="filter-select" bind:value={filterUserId} title="Filter by user">
|
||||||
|
<option value="">All users</option>
|
||||||
|
{#each allUsers as u (u.id)}
|
||||||
|
<option value={String(u.id)}>{u.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select class="filter-select" bind:value={filterAction} title="Filter by action">
|
||||||
|
<option value="">All actions</option>
|
||||||
|
{#each Object.keys(ACTION_LABELS) as a}
|
||||||
|
<option value={a}>{ACTION_LABELS[a]}</option>
|
||||||
|
{/each}
|
||||||
|
{#each knownActions.filter((a) => !(a in ACTION_LABELS)) as a}
|
||||||
|
<option value={a}>{a}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select class="filter-select" bind:value={filterObjectType} title="Filter by object type">
|
||||||
|
<option value="">All objects</option>
|
||||||
|
{#each OBJECT_TYPES as t}
|
||||||
|
<option value={t}>{t}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="filter-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Object ID…"
|
||||||
|
bind:value={filterObjectId}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters-row">
|
||||||
|
<label class="date-label">
|
||||||
|
From
|
||||||
|
<input class="filter-input date" type="datetime-local" bind:value={filterFrom} />
|
||||||
|
</label>
|
||||||
|
<label class="date-label">
|
||||||
|
To
|
||||||
|
<input class="filter-input date" type="datetime-local" bind:value={filterTo} />
|
||||||
|
</label>
|
||||||
|
{#if filtersActive}
|
||||||
|
<button class="clear-btn" onclick={clearFilters}>Clear filters</button>
|
||||||
|
{/if}
|
||||||
|
<span class="total-hint">{total} entr{total !== 1 ? 'ies' : 'y'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
{#if error}
|
||||||
|
<p class="msg error" role="alert">{error}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="content-area">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Object</th>
|
||||||
|
<th>ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each entries as e (e.id)}
|
||||||
|
<tr>
|
||||||
|
<td class="ts-cell">{formatTs(e.performed_at)}</td>
|
||||||
|
<td class="user-cell">{e.user_name ?? '—'}</td>
|
||||||
|
<td class="action-cell">
|
||||||
|
<span class="action-tag" class:file={e.object_type === 'file'} class:tag={e.object_type === 'tag'} class:pool={e.object_type === 'pool'} class:cat={e.object_type === 'category'}>
|
||||||
|
{actionLabel(e.action)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="obj-type-cell">{e.object_type ?? '—'}</td>
|
||||||
|
<td class="obj-id-cell" title={e.object_id ?? ''}>{shortId(e.object_id)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<tr class="loading-row">
|
||||||
|
<td colspan="5">
|
||||||
|
<span class="spinner" role="status" aria-label="Loading"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !loading && initialLoaded && entries.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="empty-cell">No entries match the current filters.</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="pagination">
|
||||||
|
<button class="page-btn" onclick={() => goToPage(page - 1)} disabled={page === 0 || loading}>
|
||||||
|
← Prev
|
||||||
|
</button>
|
||||||
|
<span class="page-info">Page {page + 1} of {totalPages}</span>
|
||||||
|
<button class="page-btn" onclick={() => goToPage(page + 1)} disabled={page >= totalPages - 1 || loading}>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Filters ---- */
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select,
|
||||||
|
.filter-input {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 7px;
|
||||||
|
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;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus,
|
||||||
|
.filter-input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input.date {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-danger) 45%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Table ---- */
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover td {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-tag.file { background-color: color-mix(in srgb, var(--color-info) 12%, transparent); color: var(--color-info); }
|
||||||
|
.action-tag.tag { background-color: color-mix(in srgb, #7ECBA1 12%, transparent); color: #7ECBA1; }
|
||||||
|
.action-tag.pool { background-color: color-mix(in srgb, var(--color-warning) 12%, transparent); color: var(--color-warning); }
|
||||||
|
.action-tag.cat { background-color: color-mix(in srgb, var(--color-danger) 12%, transparent); color: var(--color-danger); }
|
||||||
|
|
||||||
|
.obj-type-cell {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: capitalize;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obj-id-cell {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-row td {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-cell {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 2px 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); } }
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.error {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
415
frontend/src/routes/admin/users/+page.svelte
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
import type { User, UserOffsetPage } from '$lib/api/types';
|
||||||
|
|
||||||
|
const LIMIT = 100;
|
||||||
|
|
||||||
|
let users = $state<User[]>([]);
|
||||||
|
let total = $state(0);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
// Create form
|
||||||
|
let showCreate = $state(false);
|
||||||
|
let newName = $state('');
|
||||||
|
let newPassword = $state('');
|
||||||
|
let newCanCreate = $state(false);
|
||||||
|
let newIsAdmin = $state(false);
|
||||||
|
let creating = $state(false);
|
||||||
|
let createError = $state('');
|
||||||
|
|
||||||
|
// Delete confirm
|
||||||
|
let confirmDeleteUser = $state<User | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const res = await api.get<UserOffsetPage>(`/users?limit=${LIMIT}&offset=0`);
|
||||||
|
users = res.items ?? [];
|
||||||
|
total = res.total ?? users.length;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to load users';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser() {
|
||||||
|
if (!newName.trim() || !newPassword.trim()) return;
|
||||||
|
creating = true;
|
||||||
|
createError = '';
|
||||||
|
try {
|
||||||
|
const u = await api.post<User>('/users', {
|
||||||
|
name: newName.trim(),
|
||||||
|
password: newPassword.trim(),
|
||||||
|
can_create: newCanCreate,
|
||||||
|
is_admin: newIsAdmin,
|
||||||
|
});
|
||||||
|
users = [u, ...users];
|
||||||
|
total++;
|
||||||
|
showCreate = false;
|
||||||
|
newName = '';
|
||||||
|
newPassword = '';
|
||||||
|
newCanCreate = false;
|
||||||
|
newIsAdmin = false;
|
||||||
|
} catch (e) {
|
||||||
|
createError = e instanceof ApiError ? e.message : 'Failed to create user';
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(u: User) {
|
||||||
|
confirmDeleteUser = null;
|
||||||
|
try {
|
||||||
|
await api.delete(`/users/${u.id}`);
|
||||||
|
users = users.filter((x) => x.id !== u.id);
|
||||||
|
total--;
|
||||||
|
} catch {
|
||||||
|
// silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { void load(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Users — Admin | Tanabata</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="count">{total} user{total !== 1 ? 's' : ''}</span>
|
||||||
|
<button class="btn primary" onclick={() => (showCreate = !showCreate)}>
|
||||||
|
{showCreate ? 'Cancel' : '+ New user'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreate}
|
||||||
|
<div class="create-form">
|
||||||
|
{#if createError}<p class="form-error" role="alert">{createError}</p>{/if}
|
||||||
|
<div class="form-row">
|
||||||
|
<input class="input" type="text" placeholder="Username" bind:value={newName} autocomplete="off" />
|
||||||
|
<input class="input" type="password" placeholder="Password" bind:value={newPassword} autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row checks">
|
||||||
|
<label class="check-label">
|
||||||
|
<input type="checkbox" bind:checked={newCanCreate} />
|
||||||
|
Can create
|
||||||
|
</label>
|
||||||
|
<label class="check-label">
|
||||||
|
<input type="checkbox" bind:checked={newIsAdmin} />
|
||||||
|
Admin
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="btn primary"
|
||||||
|
onclick={createUser}
|
||||||
|
disabled={creating || !newName.trim() || !newPassword.trim()}
|
||||||
|
>
|
||||||
|
{creating ? 'Creating…' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{:else if loading}
|
||||||
|
<div class="loading"><span class="spinner" role="status" aria-label="Loading"></span></div>
|
||||||
|
{:else if users.length === 0}
|
||||||
|
<p class="empty">No users found.</p>
|
||||||
|
{:else}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each users as u (u.id)}
|
||||||
|
<tr class="user-row" class:blocked={u.is_blocked}>
|
||||||
|
<td class="id-cell">{u.id}</td>
|
||||||
|
<td class="name-cell">
|
||||||
|
<button class="name-btn" onclick={() => goto(`/admin/users/${u.id}`)}>
|
||||||
|
{u.name}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" class:admin={u.is_admin} class:creator={!u.is_admin && u.can_create}>
|
||||||
|
{u.is_admin ? 'Admin' : u.can_create ? 'Creator' : 'Viewer'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if u.is_blocked}
|
||||||
|
<span class="badge blocked">Blocked</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge active">Active</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button class="icon-btn" onclick={() => goto(`/admin/users/${u.id}`)} title="Edit">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M9.5 2.5l2 2-7 7H2.5v-2l7-7z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn danger" onclick={() => (confirmDeleteUser = u)} title="Delete">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M2 4h10M5 4V2.5h4V4M5.5 6.5v4M8.5 6.5v4M3 4l.8 7.5h6.4L11 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if confirmDeleteUser}
|
||||||
|
<ConfirmDialog
|
||||||
|
message="Delete user “{confirmDeleteUser.name}”? This cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
danger
|
||||||
|
onConfirm={() => deleteUser(confirmDeleteUser!)}
|
||||||
|
onCancel={() => (confirmDeleteUser = null)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
padding: 16px;
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row.checks {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
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.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row.blocked td {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-cell {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-btn:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.admin {
|
||||||
|
background-color: color-mix(in srgb, var(--color-warning) 20%, transparent);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.creator {
|
||||||
|
background-color: color-mix(in srgb, var(--color-info) 15%, transparent);
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.active {
|
||||||
|
background-color: color-mix(in srgb, #7ECBA1 15%, transparent);
|
||||||
|
color: #7ECBA1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.blocked {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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) 25%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn.danger:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
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); } }
|
||||||
|
|
||||||
|
.error, .empty {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error { color: var(--color-danger); }
|
||||||
|
</style>
|
||||||
339
frontend/src/routes/admin/users/[id]/+page.svelte
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
import type { User } from '$lib/api/types';
|
||||||
|
|
||||||
|
let userId = $derived(page.params.id);
|
||||||
|
|
||||||
|
let user = $state<User | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let saving = $state(false);
|
||||||
|
let saveError = $state('');
|
||||||
|
let saveSuccess = $state(false);
|
||||||
|
let confirmDelete = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
// editable fields
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let canCreate = $state(false);
|
||||||
|
let isBlocked = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = userId;
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
void api.get<User>(`/users/${id}`).then((u) => {
|
||||||
|
user = u;
|
||||||
|
isAdmin = u.is_admin ?? false;
|
||||||
|
canCreate = u.can_create ?? false;
|
||||||
|
isBlocked = u.is_blocked ?? false;
|
||||||
|
}).catch((e) => {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to load user';
|
||||||
|
}).finally(() => {
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (saving || !user) return;
|
||||||
|
saving = true;
|
||||||
|
saveError = '';
|
||||||
|
saveSuccess = false;
|
||||||
|
try {
|
||||||
|
const updated = await api.patch<User>(`/users/${user.id}`, {
|
||||||
|
is_admin: isAdmin,
|
||||||
|
can_create: canCreate,
|
||||||
|
is_blocked: isBlocked,
|
||||||
|
});
|
||||||
|
user = updated;
|
||||||
|
saveSuccess = true;
|
||||||
|
setTimeout(() => (saveSuccess = false), 2500);
|
||||||
|
} catch (e) {
|
||||||
|
saveError = e instanceof ApiError ? e.message : 'Failed to save';
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
confirmDelete = false;
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await api.delete(`/users/${user!.id}`);
|
||||||
|
goto('/admin/users');
|
||||||
|
} catch (e) {
|
||||||
|
saveError = e instanceof ApiError ? e.message : 'Failed to delete';
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{user?.name ?? 'User'} — Admin | Tanabata</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<button class="back-link" onclick={() => goto('/admin/users')}>
|
||||||
|
← All users
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="msg error" role="alert">{error}</p>
|
||||||
|
{:else if loading}
|
||||||
|
<div class="loading"><span class="spinner" role="status" aria-label="Loading"></span></div>
|
||||||
|
{:else if user}
|
||||||
|
<div class="card">
|
||||||
|
<div class="user-header">
|
||||||
|
<span class="user-name">{user.name}</span>
|
||||||
|
<span class="user-id">#{user.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if saveError}<p class="msg error" role="alert">{saveError}</p>{/if}
|
||||||
|
{#if saveSuccess}<p class="msg success" role="status">Saved.</p>{/if}
|
||||||
|
|
||||||
|
<div class="section-label">Role & permissions</div>
|
||||||
|
|
||||||
|
<div class="toggle-group">
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div>
|
||||||
|
<span class="toggle-label">Admin</span>
|
||||||
|
<p class="toggle-hint">Full access to all data and admin panel.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="toggle" class:on={isAdmin}
|
||||||
|
role="switch" aria-checked={isAdmin}
|
||||||
|
onclick={() => (isAdmin = !isAdmin)}
|
||||||
|
><span class="thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div>
|
||||||
|
<span class="toggle-label">Can create</span>
|
||||||
|
<p class="toggle-hint">Can upload files and create tags, pools, categories.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="toggle" class:on={canCreate}
|
||||||
|
role="switch" aria-checked={canCreate}
|
||||||
|
onclick={() => (canCreate = !canCreate)}
|
||||||
|
><span class="thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-label">Account status</div>
|
||||||
|
|
||||||
|
<div class="toggle-group">
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div>
|
||||||
|
<span class="toggle-label" class:danger-label={isBlocked}>Blocked</span>
|
||||||
|
<p class="toggle-hint">Blocked users cannot log in.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="toggle" class:on={isBlocked} class:danger={isBlocked}
|
||||||
|
role="switch" aria-checked={isBlocked}
|
||||||
|
onclick={() => (isBlocked = !isBlocked)}
|
||||||
|
><span class="thumb"></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-row">
|
||||||
|
<button class="btn primary" onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
<button class="btn danger-outline" onclick={() => (confirmDelete = true)} disabled={deleting}>
|
||||||
|
{deleting ? 'Deleting…' : 'Delete user'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if confirmDelete && user}
|
||||||
|
<ConfirmDialog
|
||||||
|
message="Delete user “{user.name}”? This cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
danger
|
||||||
|
onConfirm={doDelete}
|
||||||
|
onCancel={() => (confirmDelete = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
padding: 16px;
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover { color: var(--color-accent); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-id {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label.danger-label {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* toggle switch */
|
||||||
|
.toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 11px;
|
||||||
|
border: none;
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 22%, var(--color-bg-primary));
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on { background-color: var(--color-accent); }
|
||||||
|
.toggle.on.danger { background-color: var(--color-danger); }
|
||||||
|
|
||||||
|
.toggle .thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on .thumb { transform: translateX(18px); }
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger-outline {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-danger);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger-outline:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.error { color: var(--color-danger); }
|
||||||
|
.msg.success { color: #7ECBA1; }
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
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); } }
|
||||||
|
</style>
|
||||||
@ -13,8 +13,12 @@
|
|||||||
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
|
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { parseDslFilter } from '$lib/utils/dsl';
|
import { parseDslFilter } from '$lib/utils/dsl';
|
||||||
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
|
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
|
||||||
|
import { appSettings } from '$lib/stores/appSettings';
|
||||||
|
|
||||||
|
let scrollContainer = $state<HTMLElement | undefined>();
|
||||||
|
|
||||||
let uploader = $state<{ open: () => void } | undefined>();
|
let uploader = $state<{ open: () => void } | undefined>();
|
||||||
let confirmDeleteFiles = $state(false);
|
let confirmDeleteFiles = $state(false);
|
||||||
@ -65,7 +69,7 @@
|
|||||||
files = [file, ...files];
|
files = [file, ...files];
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIMIT = 50;
|
let LIMIT = $derived($appSettings.fileLoadLimit);
|
||||||
|
|
||||||
const FILE_SORT_OPTIONS = [
|
const FILE_SORT_OPTIONS = [
|
||||||
{ value: 'created', label: 'Created' },
|
{ value: 'created', label: 'Created' },
|
||||||
@ -120,6 +124,12 @@
|
|||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
// If the loaded content doesn't fill the viewport yet (no scrollbar),
|
||||||
|
// keep loading until it does or there's nothing left.
|
||||||
|
await tick();
|
||||||
|
if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
|
||||||
|
void loadMore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilter(filter: string | null) {
|
function applyFilter(filter: string | null) {
|
||||||
@ -232,6 +242,7 @@
|
|||||||
onOrderToggle={() => fileSorting.toggleOrder()}
|
onOrderToggle={() => fileSorting.toggleOrder()}
|
||||||
onFilterToggle={() => (filterOpen = !filterOpen)}
|
onFilterToggle={() => (filterOpen = !filterOpen)}
|
||||||
onUpload={() => uploader?.open()}
|
onUpload={() => uploader?.open()}
|
||||||
|
onTrash={() => goto('/files/trash')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if filterOpen}
|
{#if filterOpen}
|
||||||
@ -243,7 +254,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<FileUpload bind:this={uploader} onUploaded={handleUploaded}>
|
<FileUpload bind:this={uploader} onUploaded={handleUploaded}>
|
||||||
<main>
|
<main bind:this={scrollContainer}>
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
405
frontend/src/routes/files/trash/+page.svelte
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import FileCard from '$lib/components/file/FileCard.svelte';
|
||||||
|
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
|
import { selectionStore, selectionActive, selectionCount } from '$lib/stores/selection';
|
||||||
|
import { appSettings } from '$lib/stores/appSettings';
|
||||||
|
import type { File, FileCursorPage } from '$lib/api/types';
|
||||||
|
|
||||||
|
let scrollContainer = $state<HTMLElement | undefined>();
|
||||||
|
|
||||||
|
let LIMIT = $derived($appSettings.fileLoadLimit);
|
||||||
|
|
||||||
|
let files = $state<File[]>([]);
|
||||||
|
let nextCursor = $state<string | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let hasMore = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let initialLoaded = $state(false);
|
||||||
|
|
||||||
|
// confirmation dialogs
|
||||||
|
let confirmRestore = $state(false);
|
||||||
|
let confirmPermDelete = $state(false);
|
||||||
|
let actionBusy = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!initialLoaded && !loading) void loadMore();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (loading || !hasMore) return;
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ limit: String(LIMIT), trash: 'true' });
|
||||||
|
if (nextCursor) params.set('cursor', nextCursor);
|
||||||
|
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
|
files = [...files, ...(res.items ?? [])];
|
||||||
|
nextCursor = res.next_cursor ?? null;
|
||||||
|
hasMore = !!res.next_cursor;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : 'Failed to load trash';
|
||||||
|
hasMore = false;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
initialLoaded = true;
|
||||||
|
}
|
||||||
|
await tick();
|
||||||
|
if (hasMore && scrollContainer && scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
|
||||||
|
void loadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Selection ----
|
||||||
|
let lastSelectedIdx = $state<number | null>(null);
|
||||||
|
let dragSelecting = $state(false);
|
||||||
|
let dragMode = $state<'select' | 'deselect'>('select');
|
||||||
|
|
||||||
|
function handleTap(file: File, idx: number, e: MouseEvent) {
|
||||||
|
// In trash, tap always selects (no detail page)
|
||||||
|
if (e.shiftKey && lastSelectedIdx !== null) {
|
||||||
|
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!);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!$selectionActive) selectionStore.enter();
|
||||||
|
if (file.id) selectionStore.toggle(file.id);
|
||||||
|
}
|
||||||
|
lastSelectedIdx = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLongPress(file: File, idx: number, pointerType: string) {
|
||||||
|
const alreadySelected = $selectionStore.ids.has(file.id!);
|
||||||
|
if (alreadySelected) {
|
||||||
|
selectionStore.deselect(file.id!);
|
||||||
|
dragMode = 'deselect';
|
||||||
|
} else {
|
||||||
|
selectionStore.select(file.id!);
|
||||||
|
dragMode = 'select';
|
||||||
|
}
|
||||||
|
lastSelectedIdx = idx;
|
||||||
|
if (pointerType === 'touch') dragSelecting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!dragSelecting) return;
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Actions ----
|
||||||
|
async function restoreSelected() {
|
||||||
|
const ids = [...$selectionStore.ids];
|
||||||
|
confirmRestore = false;
|
||||||
|
actionBusy = true;
|
||||||
|
selectionStore.exit();
|
||||||
|
try {
|
||||||
|
await Promise.all(ids.map((id) => api.post(`/files/${id}/restore`, {})));
|
||||||
|
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
||||||
|
} catch {
|
||||||
|
// partial failure: reload
|
||||||
|
} finally {
|
||||||
|
actionBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function permDeleteSelected() {
|
||||||
|
const ids = [...$selectionStore.ids];
|
||||||
|
confirmPermDelete = false;
|
||||||
|
actionBusy = true;
|
||||||
|
selectionStore.exit();
|
||||||
|
try {
|
||||||
|
await Promise.all(ids.map((id) => api.delete(`/files/${id}/permanent`)));
|
||||||
|
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
||||||
|
} catch {
|
||||||
|
// partial failure: reload
|
||||||
|
} finally {
|
||||||
|
actionBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') selectionStore.exit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Trash | Tanabata</title></svelte:head>
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<header>
|
||||||
|
<button class="back-btn" onclick={() => { selectionStore.exit(); goto('/files'); }}>
|
||||||
|
← Files
|
||||||
|
</button>
|
||||||
|
<span class="title">Trash</span>
|
||||||
|
<button
|
||||||
|
class="select-btn"
|
||||||
|
class:active={$selectionActive}
|
||||||
|
onclick={() => ($selectionActive ? selectionStore.exit() : selectionStore.enter())}
|
||||||
|
>
|
||||||
|
{$selectionActive ? 'Cancel' : 'Select'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main bind:this={scrollContainer}>
|
||||||
|
{#if error}
|
||||||
|
<p class="error" role="alert">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
{#each files as file, i (file.id)}
|
||||||
|
<FileCard
|
||||||
|
{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">Trash is empty.</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $selectionActive}
|
||||||
|
<div class="sel-bar" role="toolbar" aria-label="Trash selection actions">
|
||||||
|
<button class="sel-count" onclick={() => selectionStore.exit()} title="Clear selection">
|
||||||
|
<span class="sel-num">{$selectionCount}</span>
|
||||||
|
<span class="sel-label">selected</span>
|
||||||
|
<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>
|
||||||
|
<div class="sel-spacer"></div>
|
||||||
|
<button class="sel-action restore" onclick={() => (confirmRestore = true)} disabled={actionBusy}>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
<button class="sel-action perm-delete" onclick={() => (confirmPermDelete = true)} disabled={actionBusy}>
|
||||||
|
Delete permanently
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmRestore}
|
||||||
|
<ConfirmDialog
|
||||||
|
message={`Restore ${$selectionStore.ids.size} file(s)?`}
|
||||||
|
confirmLabel="Restore"
|
||||||
|
onConfirm={restoreSelected}
|
||||||
|
onCancel={() => (confirmRestore = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmPermDelete}
|
||||||
|
<ConfirmDialog
|
||||||
|
message={`Permanently delete ${$selectionStore.ids.size} file(s)? This cannot be undone.`}
|
||||||
|
confirmLabel="Delete permanently"
|
||||||
|
danger
|
||||||
|
onConfirm={permDeleteSelected}
|
||||||
|
onCancel={() => (confirmPermDelete = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover { color: var(--color-accent); }
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 10px calc(60px + 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid::after {
|
||||||
|
content: '';
|
||||||
|
flex: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 60px 20px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Trash selection bar ---- */
|
||||||
|
.sel-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;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
animation: slide-up 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { transform: translateY(12px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-count:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-num {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-label { font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.sel-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.sel-action {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-action:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.sel-action.restore {
|
||||||
|
color: #7ECBA1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-action.restore:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, #7ECBA1 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-action.perm-delete {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-action.perm-delete:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
600
frontend/src/routes/settings/+page.svelte
Normal file
@ -0,0 +1,600 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api, ApiError } from '$lib/api/client';
|
||||||
|
import { authStore } from '$lib/stores/auth';
|
||||||
|
import { themeStore, toggleTheme } from '$lib/stores/theme';
|
||||||
|
import { appSettings } from '$lib/stores/appSettings';
|
||||||
|
import { resetPwa as doPwaReset } from '$lib/utils/pwa';
|
||||||
|
import type { User, Session, SessionList } from '$lib/api/types';
|
||||||
|
|
||||||
|
// ---- Profile ----
|
||||||
|
let userName = $state($authStore.user?.name ?? '');
|
||||||
|
let password = $state('');
|
||||||
|
let passwordConfirm = $state('');
|
||||||
|
let profileSaving = $state(false);
|
||||||
|
let profileSuccess = $state(false);
|
||||||
|
let profileError = $state('');
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
profileError = '';
|
||||||
|
profileSuccess = false;
|
||||||
|
if (!userName.trim()) {
|
||||||
|
profileError = 'Name is required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password && password !== passwordConfirm) {
|
||||||
|
profileError = 'Passwords do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
profileSaving = true;
|
||||||
|
try {
|
||||||
|
const body: Record<string, string> = { name: userName.trim() };
|
||||||
|
if (password) body.password = password;
|
||||||
|
const updated = await api.patch<User>('/users/me', body);
|
||||||
|
authStore.update((s) => ({
|
||||||
|
...s,
|
||||||
|
user: s.user ? { ...s.user, name: updated.name ?? s.user.name } : s.user,
|
||||||
|
}));
|
||||||
|
password = '';
|
||||||
|
passwordConfirm = '';
|
||||||
|
profileSuccess = true;
|
||||||
|
setTimeout(() => (profileSuccess = false), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
profileError = e instanceof ApiError ? e.message : 'Failed to save';
|
||||||
|
} finally {
|
||||||
|
profileSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sessions ----
|
||||||
|
let sessions = $state<Session[]>([]);
|
||||||
|
let sessionsTotal = $state(0);
|
||||||
|
let sessionsLoading = $state(true);
|
||||||
|
let sessionsError = $state('');
|
||||||
|
let terminatingIds = $state(new Set<number>());
|
||||||
|
|
||||||
|
async function loadSessions() {
|
||||||
|
sessionsLoading = true;
|
||||||
|
sessionsError = '';
|
||||||
|
try {
|
||||||
|
const res = await api.get<SessionList>('/auth/sessions');
|
||||||
|
sessions = res.items ?? [];
|
||||||
|
sessionsTotal = res.total ?? sessions.length;
|
||||||
|
} catch (e) {
|
||||||
|
sessionsError = e instanceof ApiError ? e.message : 'Failed to load sessions';
|
||||||
|
} finally {
|
||||||
|
sessionsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminateSession(id: number) {
|
||||||
|
terminatingIds = new Set([...terminatingIds, id]);
|
||||||
|
try {
|
||||||
|
await api.delete(`/auth/sessions/${id}`);
|
||||||
|
sessions = sessions.filter((s) => s.id !== id);
|
||||||
|
sessionsTotal = Math.max(0, sessionsTotal - 1);
|
||||||
|
} catch {
|
||||||
|
// silently ignore
|
||||||
|
} finally {
|
||||||
|
terminatingIds.delete(id);
|
||||||
|
terminatingIds = new Set(terminatingIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void loadSessions();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- PWA reset ----
|
||||||
|
let pwaResetting = $state(false);
|
||||||
|
let pwaSuccess = $state(false);
|
||||||
|
|
||||||
|
async function resetPwa() {
|
||||||
|
pwaResetting = true;
|
||||||
|
pwaSuccess = false;
|
||||||
|
try {
|
||||||
|
await doPwaReset();
|
||||||
|
pwaSuccess = true;
|
||||||
|
setTimeout(() => (pwaSuccess = false), 3000);
|
||||||
|
} finally {
|
||||||
|
pwaResetting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
function formatDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortUserAgent(ua: string | null | undefined): string {
|
||||||
|
if (!ua) return 'Unknown';
|
||||||
|
// Extract browser + OS from UA string
|
||||||
|
const browser =
|
||||||
|
ua.match(/\b(Chrome|Firefox|Safari|Edge|Opera|Brave)\/[\d.]+/)?.[0] ??
|
||||||
|
ua.match(/\b(MSIE|Trident)\b/)?.[0] ??
|
||||||
|
ua.slice(0, 40);
|
||||||
|
const os =
|
||||||
|
ua.match(/\((Windows[^;)]*|Mac OS X [^;)]*|Linux[^;)]*|Android [^;)]*|iOS [^;)]*)/)?.[1] ?? '';
|
||||||
|
return os ? `${browser} · ${os}` : browser;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Settings | Tanabata</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<!-- ====== Profile ====== -->
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="section-title">Profile</h2>
|
||||||
|
|
||||||
|
{#if profileError}
|
||||||
|
<p class="msg error" role="alert">{profileError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if profileSuccess}
|
||||||
|
<p class="msg success" role="status">Saved.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
bind:value={userName}
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
placeholder="Your display name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="password">New password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
class="input"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Leave blank to keep current"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if password}
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="password-confirm">Confirm password</label>
|
||||||
|
<input
|
||||||
|
id="password-confirm"
|
||||||
|
class="input"
|
||||||
|
type="password"
|
||||||
|
bind:value={passwordConfirm}
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Repeat new password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="row-actions">
|
||||||
|
<button
|
||||||
|
class="btn primary"
|
||||||
|
onclick={saveProfile}
|
||||||
|
disabled={profileSaving || !userName.trim()}
|
||||||
|
>
|
||||||
|
{profileSaving ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== Appearance ====== -->
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="section-title">Appearance</h2>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="toggle-label">
|
||||||
|
{$themeStore === 'light' ? 'Light theme' : 'Dark theme'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="theme-toggle"
|
||||||
|
onclick={toggleTheme}
|
||||||
|
title="Toggle theme"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{#if $themeStore === 'light'}
|
||||||
|
<!-- Sun icon -->
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="9" cy="9" r="3.5" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M9 1v2M9 15v2M1 9h2M15 9h2M3.22 3.22l1.41 1.41M13.36 13.36l1.42 1.42M3.22 14.78l1.41-1.41M13.36 4.64l1.42-1.42" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Switch to dark
|
||||||
|
{:else}
|
||||||
|
<!-- Moon icon -->
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path d="M15 11.5A7 7 0 0 1 6.5 3a7.001 7.001 0 1 0 8.5 8.5z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Switch to light
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== PWA ====== -->
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="section-title">App cache</h2>
|
||||||
|
<p class="hint-text">Clear service worker and cached assets. Useful if the app feels stale after an update.</p>
|
||||||
|
{#if pwaSuccess}
|
||||||
|
<p class="msg success" role="status">Cache cleared. Reload the page to fetch fresh assets.</p>
|
||||||
|
{/if}
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="btn danger-outline" onclick={resetPwa} disabled={pwaResetting}>
|
||||||
|
{pwaResetting ? 'Clearing…' : 'Clear PWA cache'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== App settings ====== -->
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="section-title">Behaviour</h2>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="file-limit">Files per page</label>
|
||||||
|
<p class="hint-text">How many files to load in one batch when scrolling the file list.</p>
|
||||||
|
<input
|
||||||
|
id="file-limit"
|
||||||
|
class="input input-narrow"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="500"
|
||||||
|
step="1"
|
||||||
|
value={$appSettings.fileLoadLimit}
|
||||||
|
oninput={(e) => {
|
||||||
|
const v = parseInt((e.currentTarget as HTMLInputElement).value, 10);
|
||||||
|
if (!isNaN(v) && v >= 10 && v <= 500)
|
||||||
|
appSettings.update((s) => ({ ...s, fileLoadLimit: v }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div>
|
||||||
|
<span class="toggle-label">Apply activated tag rules to existing files</span>
|
||||||
|
<p class="hint-text">When a tag rule is activated, automatically add the implied tag to all files that already have the source tag.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
class:on={$appSettings.tagRuleApplyToExisting}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={$appSettings.tagRuleApplyToExisting}
|
||||||
|
aria-label="Apply activated tag rules to existing files"
|
||||||
|
onclick={() => appSettings.update((s) => ({ ...s, tagRuleApplyToExisting: !s.tagRuleApplyToExisting }))}
|
||||||
|
>
|
||||||
|
<span class="thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ====== Sessions ====== -->
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="section-title">
|
||||||
|
Active sessions
|
||||||
|
{#if sessionsTotal > 0}<span class="count">({sessionsTotal})</span>{/if}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if sessionsError}
|
||||||
|
<p class="msg error" role="alert">{sessionsError}</p>
|
||||||
|
{:else if sessionsLoading}
|
||||||
|
<p class="msg muted">Loading…</p>
|
||||||
|
{:else if sessions.length === 0}
|
||||||
|
<p class="msg muted">No active sessions.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="sessions-list">
|
||||||
|
{#each sessions as session (session.id)}
|
||||||
|
<li class="session-item" class:current={session.is_current}>
|
||||||
|
<div class="session-info">
|
||||||
|
<span class="session-ua">{shortUserAgent(session.user_agent)}</span>
|
||||||
|
{#if session.is_current}
|
||||||
|
<span class="current-badge">current</span>
|
||||||
|
{/if}
|
||||||
|
<span class="session-meta">
|
||||||
|
Started {formatDate(session.started_at)}
|
||||||
|
{#if session.expires_at}· Expires {formatDate(session.expires_at)}{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if !session.is_current}
|
||||||
|
<button
|
||||||
|
class="terminate-btn"
|
||||||
|
onclick={() => session.id != null && terminateSession(session.id)}
|
||||||
|
disabled={terminatingIds.has(session.id ?? -1)}
|
||||||
|
aria-label="Terminate session"
|
||||||
|
>
|
||||||
|
{terminatingIds.has(session.id ?? -1) ? '…' : 'End'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 12px calc(70px + 16px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
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.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger-outline {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-danger);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger-outline:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.error { color: var(--color-danger); }
|
||||||
|
.msg.success { color: #7ECBA1; }
|
||||||
|
.msg.muted { color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
/* ---- Appearance toggle ---- */
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-narrow {
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On/off toggle switch */
|
||||||
|
.toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
width: 42px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 25%, var(--color-bg-primary));
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle .thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on .thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- PWA ---- */
|
||||||
|
.hint-text {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Sessions ---- */
|
||||||
|
.sessions-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item.current {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-ua {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-accent);
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminate-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-danger) 50%, transparent);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminate-btn:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminate-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
68
frontend/src/service-worker.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import { build, files, version } from '$service-worker';
|
||||||
|
|
||||||
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
// Cache name is versioned so a new deploy invalidates the old shell.
|
||||||
|
const CACHE = `app-shell-${version}`;
|
||||||
|
|
||||||
|
// App shell: all Vite-emitted JS/CSS chunks + static assets (fonts, icons, manifest).
|
||||||
|
const SHELL = [...build, ...files];
|
||||||
|
|
||||||
|
// ---- Install: pre-cache the app shell ----
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE).then((cache) => cache.addAll(SHELL))
|
||||||
|
);
|
||||||
|
// Activate immediately without waiting for old tabs to close.
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Activate: remove stale caches from previous versions ----
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Fetch: cache-first for shell assets, network-only for API ----
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Only handle same-origin GET requests.
|
||||||
|
if (request.method !== 'GET' || url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// API and authentication calls must always go to the network.
|
||||||
|
if (url.pathname.startsWith('/api/')) return;
|
||||||
|
|
||||||
|
event.respondWith(respond(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function respond(request: Request): Promise<Response> {
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
|
||||||
|
// Shell assets are pre-cached — serve from cache immediately.
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// Everything else (navigation, dynamic routes): network first.
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
// Cache successful responses for navigation so the app works offline.
|
||||||
|
if (response.status === 200) {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
// Offline fallback: return the cached SPA shell for navigation requests.
|
||||||
|
const fallback = await cache.match('/');
|
||||||
|
if (fallback) return fallback;
|
||||||
|
return new Response('Offline', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/static/browserconfig.xml
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square70x70logo src="/images/ms-icon-70x70.png" />
|
||||||
|
<square150x150logo src="/images/ms-icon-150x150.png" />
|
||||||
|
<square310x310logo src="/images/ms-icon-310x310.png" />
|
||||||
|
<TileColor>#615880</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
BIN
frontend/static/favicon.ico
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/static/images/android-icon-144x144.png
vendored
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/static/images/android-icon-192x192.png
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/static/images/android-icon-36x36.png
vendored
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/static/images/android-icon-48x48.png
vendored
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
frontend/static/images/android-icon-72x72.png
vendored
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
frontend/static/images/android-icon-96x96.png
vendored
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
frontend/static/images/apple-icon-114x114.png
vendored
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
frontend/static/images/apple-icon-120x120.png
vendored
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
frontend/static/images/apple-icon-144x144.png
vendored
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/static/images/apple-icon-152x152.png
vendored
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/static/images/apple-icon-180x180.png
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/static/images/apple-icon-57x57.png
vendored
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/static/images/apple-icon-60x60.png
vendored
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/static/images/apple-icon-72x72.png
vendored
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
frontend/static/images/apple-icon-76x76.png
vendored
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/static/images/apple-icon-precomposed.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/static/images/apple-icon.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/static/images/favicon-16x16.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/static/images/favicon-32x32.png
vendored
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend/static/images/favicon-96x96.png
vendored
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
frontend/static/images/favicon-bg.png
vendored
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
frontend/static/images/favicon.png
vendored
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
frontend/static/images/ms-icon-144x144.png
vendored
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/static/images/ms-icon-150x150.png
vendored
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/static/images/ms-icon-310x310.png
vendored
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/static/images/ms-icon-70x70.png
vendored
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
55
frontend/static/manifest.webmanifest
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "Tanabata File Manager",
|
||||||
|
"short_name": "Tanabata",
|
||||||
|
"lang": "en-US",
|
||||||
|
"description": "Multi-user tag-based file manager",
|
||||||
|
"start_url": "/files",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#312F45",
|
||||||
|
"theme_color": "#312F45",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/images/android-icon-36x36.png",
|
||||||
|
"sizes": "36x36",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/images/android-icon-48x48.png",
|
||||||
|
"sizes": "48x48",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/images/android-icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/images/android-icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/images/android-icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/images/android-icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/images/apple-icon-180x180.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/images/ms-icon-310x310.png",
|
||||||
|
"sizes": "310x310",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -50,6 +50,56 @@ const ME = {
|
|||||||
is_blocked: false,
|
is_blocked: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MockUser = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
can_create: boolean;
|
||||||
|
is_blocked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUsersArr: MockUser[] = [
|
||||||
|
{ id: 1, name: 'admin', is_admin: true, can_create: true, is_blocked: false },
|
||||||
|
{ id: 2, name: 'alice', is_admin: false, can_create: true, is_blocked: false },
|
||||||
|
{ id: 3, name: 'bob', is_admin: false, can_create: true, is_blocked: false },
|
||||||
|
{ id: 4, name: 'charlie', is_admin: false, can_create: false, is_blocked: true },
|
||||||
|
{ id: 5, name: 'diana', is_admin: false, can_create: true, is_blocked: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUDIT_ACTIONS = [
|
||||||
|
'file_create', 'file_edit', 'file_delete', 'file_tag_add', 'file_tag_remove',
|
||||||
|
'tag_create', 'tag_edit', 'tag_delete', 'pool_create', 'pool_edit', 'pool_delete',
|
||||||
|
'category_create', 'category_edit',
|
||||||
|
];
|
||||||
|
const AUDIT_OBJECT_TYPES = ['file', 'tag', 'pool', 'category'];
|
||||||
|
|
||||||
|
type MockAuditEntry = {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
user_name: string;
|
||||||
|
action: string;
|
||||||
|
object_type: string | null;
|
||||||
|
object_id: string | null;
|
||||||
|
details: Record<string, unknown> | null;
|
||||||
|
performed_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuditLog: MockAuditEntry[] = Array.from({ length: 80 }, (_, i) => {
|
||||||
|
const user = mockUsersArr[i % mockUsersArr.length];
|
||||||
|
const action = AUDIT_ACTIONS[i % AUDIT_ACTIONS.length];
|
||||||
|
const objType = AUDIT_OBJECT_TYPES[i % AUDIT_OBJECT_TYPES.length];
|
||||||
|
return {
|
||||||
|
id: i + 1,
|
||||||
|
user_id: user.id,
|
||||||
|
user_name: user.name,
|
||||||
|
action,
|
||||||
|
object_type: objType,
|
||||||
|
object_id: `00000000-0000-7000-8000-${String(i + 1).padStart(12, '0')}`,
|
||||||
|
details: null,
|
||||||
|
performed_at: new Date(Date.now() - i * 1_800_000).toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const THUMB_COLORS = [
|
const THUMB_COLORS = [
|
||||||
'#9592B5', '#4DC7ED', '#DB6060', '#F5E872', '#7ECBA1',
|
'#9592B5', '#4DC7ED', '#DB6060', '#F5E872', '#7ECBA1',
|
||||||
'#E08C5A', '#A67CB8', '#5A9ED4', '#C4A44A', '#6DB89E',
|
'#E08C5A', '#A67CB8', '#5A9ED4', '#C4A44A', '#6DB89E',
|
||||||
@ -64,6 +114,31 @@ function mockThumbSvg(id: string): string {
|
|||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trash — pre-seeded with a few deleted files
|
||||||
|
const MOCK_TRASH: MockFile[] = Array.from({ length: 6 }, (_, i) => {
|
||||||
|
const mimes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
const exts = ['jpg', 'png', 'webp' ];
|
||||||
|
const mi = i % mimes.length;
|
||||||
|
const id = `00000000-0000-7000-8000-trash${String(i + 1).padStart(7, '0')}`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
original_name: `deleted-${String(i + 1).padStart(3, '0')}.${exts[mi]}`,
|
||||||
|
mime_type: mimes[mi],
|
||||||
|
mime_extension: exts[mi],
|
||||||
|
content_datetime: new Date(Date.now() - (i + 80) * 3_600_000).toISOString(),
|
||||||
|
notes: null,
|
||||||
|
metadata: null,
|
||||||
|
exif: {},
|
||||||
|
phash: null,
|
||||||
|
creator_id: 1,
|
||||||
|
creator_name: 'admin',
|
||||||
|
is_public: false,
|
||||||
|
is_deleted: true,
|
||||||
|
created_at: new Date(Date.now() - (i + 80) * 3_600_000).toISOString(),
|
||||||
|
position: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const MOCK_FILES = Array.from({ length: 75 }, (_, i) => {
|
const MOCK_FILES = Array.from({ length: 75 }, (_, i) => {
|
||||||
const mimes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'];
|
const mimes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'];
|
||||||
const exts = ['jpg', 'png', 'webp', 'mp4' ];
|
const exts = ['jpg', 'png', 'webp', 'mp4' ];
|
||||||
@ -300,6 +375,19 @@ export function mockApiPlugin(): Plugin {
|
|||||||
return json(res, 200, ME);
|
return json(res, 200, ME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PATCH /users/me
|
||||||
|
if (method === 'PATCH' && path === '/users/me') {
|
||||||
|
const body = (await readBody(req)) as { name?: string; password?: string };
|
||||||
|
if (body.name) ME.name = body.name;
|
||||||
|
return json(res, 200, ME);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /auth/sessions/{id}
|
||||||
|
const sessionDelMatch = path.match(/^\/auth\/sessions\/(\d+)$/);
|
||||||
|
if (method === 'DELETE' && sessionDelMatch) {
|
||||||
|
return noContent(res);
|
||||||
|
}
|
||||||
|
|
||||||
// GET /files/{id}/thumbnail
|
// GET /files/{id}/thumbnail
|
||||||
const thumbMatch = path.match(/^\/files\/([^/]+)\/thumbnail$/);
|
const thumbMatch = path.match(/^\/files\/([^/]+)\/thumbnail$/);
|
||||||
if (method === 'GET' && thumbMatch) {
|
if (method === 'GET' && thumbMatch) {
|
||||||
@ -400,16 +488,43 @@ export function mockApiPlugin(): Plugin {
|
|||||||
return json(res, 200, { applied_tag_ids: action === 'add' ? tagIds : [] });
|
return json(res, 200, { applied_tag_ids: action === 'add' ? tagIds : [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /files/bulk/delete — soft delete (just remove from mock array)
|
// POST /files/bulk/delete — soft delete (move to trash)
|
||||||
if (method === 'POST' && path === '/files/bulk/delete') {
|
if (method === 'POST' && path === '/files/bulk/delete') {
|
||||||
const body = (await readBody(req)) as { file_ids?: string[] };
|
const body = (await readBody(req)) as { file_ids?: string[] };
|
||||||
const ids = new Set(body.file_ids ?? []);
|
const ids = new Set(body.file_ids ?? []);
|
||||||
for (let i = MOCK_FILES.length - 1; i >= 0; i--) {
|
for (let i = MOCK_FILES.length - 1; i >= 0; i--) {
|
||||||
if (ids.has(MOCK_FILES[i].id)) MOCK_FILES.splice(i, 1);
|
if (ids.has(MOCK_FILES[i].id)) {
|
||||||
|
const [f] = MOCK_FILES.splice(i, 1);
|
||||||
|
MOCK_TRASH.unshift({ ...f, is_deleted: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return noContent(res);
|
return noContent(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /files/{id}/restore
|
||||||
|
const fileRestoreMatch = path.match(/^\/files\/([^/]+)\/restore$/);
|
||||||
|
if (method === 'POST' && fileRestoreMatch) {
|
||||||
|
const id = fileRestoreMatch[1];
|
||||||
|
const idx = MOCK_TRASH.findIndex((f) => f.id === id);
|
||||||
|
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'File not in trash' });
|
||||||
|
const [f] = MOCK_TRASH.splice(idx, 1);
|
||||||
|
const restored = { ...f, is_deleted: false };
|
||||||
|
MOCK_FILES.unshift(restored);
|
||||||
|
fileOverrides.delete(id);
|
||||||
|
return json(res, 200, restored);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /files/{id}/permanent
|
||||||
|
const filePermMatch = path.match(/^\/files\/([^/]+)\/permanent$/);
|
||||||
|
if (method === 'DELETE' && filePermMatch) {
|
||||||
|
const id = filePermMatch[1];
|
||||||
|
const idx = MOCK_TRASH.findIndex((f) => f.id === id);
|
||||||
|
if (idx < 0) return json(res, 404, { code: 'not_found', message: 'File not in trash' });
|
||||||
|
MOCK_TRASH.splice(idx, 1);
|
||||||
|
fileOverrides.delete(id);
|
||||||
|
return noContent(res);
|
||||||
|
}
|
||||||
|
|
||||||
// POST /files — upload (mock: drain body, return a new fake file)
|
// POST /files — upload (mock: drain body, return a new fake file)
|
||||||
if (method === 'POST' && path === '/files') {
|
if (method === 'POST' && path === '/files') {
|
||||||
// Drain the multipart body without parsing it
|
// Drain the multipart body without parsing it
|
||||||
@ -442,9 +557,20 @@ export function mockApiPlugin(): Plugin {
|
|||||||
return json(res, 201, newFile);
|
return json(res, 201, newFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /files (cursor pagination + anchor support)
|
// GET /files (cursor pagination + anchor support + trash)
|
||||||
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 trashMode = qs.get('trash') === 'true';
|
||||||
|
if (trashMode) {
|
||||||
|
const cursor = qs.get('cursor');
|
||||||
|
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
|
||||||
|
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
|
||||||
|
const slice = MOCK_TRASH.slice(offset, offset + limit);
|
||||||
|
const nextOffset = offset + slice.length;
|
||||||
|
const next_cursor = nextOffset < MOCK_TRASH.length
|
||||||
|
? Buffer.from(String(nextOffset)).toString('base64') : null;
|
||||||
|
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
|
||||||
|
}
|
||||||
const anchor = qs.get('anchor');
|
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);
|
||||||
@ -840,6 +966,82 @@ export function mockApiPlugin(): Plugin {
|
|||||||
return json(res, 201, newPool);
|
return json(res, 201, newPool);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /users/{id}
|
||||||
|
const userGetMatch = path.match(/^\/users\/(\d+)$/);
|
||||||
|
if (method === 'GET' && userGetMatch) {
|
||||||
|
const u = mockUsersArr.find((x) => x.id === Number(userGetMatch[1]));
|
||||||
|
if (!u) return json(res, 404, { code: 'not_found', message: 'User not found' });
|
||||||
|
return json(res, 200, u);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /users/{id}
|
||||||
|
const userPatchMatch = path.match(/^\/users\/(\d+)$/);
|
||||||
|
if (method === 'PATCH' && userPatchMatch) {
|
||||||
|
const u = mockUsersArr.find((x) => x.id === Number(userPatchMatch[1]));
|
||||||
|
if (!u) return json(res, 404, { code: 'not_found', message: 'User not found' });
|
||||||
|
const body = (await readBody(req)) as Partial<MockUser>;
|
||||||
|
Object.assign(u, body);
|
||||||
|
return json(res, 200, u);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /users/{id}
|
||||||
|
const userDelMatch = path.match(/^\/users\/(\d+)$/);
|
||||||
|
if (method === 'DELETE' && userDelMatch) {
|
||||||
|
const idx = mockUsersArr.findIndex((x) => x.id === Number(userDelMatch[1]));
|
||||||
|
if (idx >= 0) mockUsersArr.splice(idx, 1);
|
||||||
|
return noContent(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /users
|
||||||
|
if (method === 'GET' && path === '/users') {
|
||||||
|
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
||||||
|
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
|
||||||
|
const offset = Number(qs.get('offset') ?? 0);
|
||||||
|
const items = mockUsersArr.slice(offset, offset + limit);
|
||||||
|
return json(res, 200, { items, total: mockUsersArr.length, offset, limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /users
|
||||||
|
if (method === 'POST' && path === '/users') {
|
||||||
|
const body = (await readBody(req)) as Partial<MockUser> & { password?: string };
|
||||||
|
const newUser: MockUser = {
|
||||||
|
id: Math.max(...mockUsersArr.map((u) => u.id)) + 1,
|
||||||
|
name: body.name ?? 'unnamed',
|
||||||
|
is_admin: body.is_admin ?? false,
|
||||||
|
can_create: body.can_create ?? false,
|
||||||
|
is_blocked: false,
|
||||||
|
};
|
||||||
|
mockUsersArr.push(newUser);
|
||||||
|
return json(res, 201, newUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /audit
|
||||||
|
if (method === 'GET' && path === '/audit') {
|
||||||
|
const qs = new URLSearchParams(url.split('?')[1] ?? '');
|
||||||
|
const limit = Math.min(Number(qs.get('limit') ?? 50), 200);
|
||||||
|
const offset = Number(qs.get('offset') ?? 0);
|
||||||
|
const filterUserId = qs.get('user_id') ? Number(qs.get('user_id')) : null;
|
||||||
|
const filterAction = qs.get('action') ?? '';
|
||||||
|
const filterObjectType = qs.get('object_type') ?? '';
|
||||||
|
const filterObjectId = qs.get('object_id') ?? '';
|
||||||
|
const filterFrom = qs.get('from') ? new Date(qs.get('from')!).getTime() : null;
|
||||||
|
const filterTo = qs.get('to') ? new Date(qs.get('to')!).getTime() : null;
|
||||||
|
|
||||||
|
let filtered = mockAuditLog.filter((e) => {
|
||||||
|
if (filterUserId !== null && e.user_id !== filterUserId) return false;
|
||||||
|
if (filterAction && e.action !== filterAction) return false;
|
||||||
|
if (filterObjectType && e.object_type !== filterObjectType) return false;
|
||||||
|
if (filterObjectId && e.object_id !== filterObjectId) return false;
|
||||||
|
const t = new Date(e.performed_at).getTime();
|
||||||
|
if (filterFrom !== null && t < filterFrom) return false;
|
||||||
|
if (filterTo !== null && t > filterTo) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = filtered.slice(offset, offset + limit);
|
||||||
|
return json(res, 200, { items, total: filtered.length, offset, limit });
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: 404
|
// Fallback: 404
|
||||||
return json(res, 404, { code: 'not_found', message: `Mock: no handler for ${method} ${path}` });
|
return json(res, 404, { code: 'not_found', message: `Mock: no handler for ${method} ${path}` });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -846,6 +846,10 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
is_active:
|
is_active:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
apply_to_existing:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: When activating, apply rule retroactively to files already tagged
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Rule updated
|
description: Rule updated
|
||||||
|
|||||||