mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat(ui): rewrite frontend in React with Vite
- Scaffold Vite+React project in frontend/ (bun, react 18, @vitejs/plugin-react) - vite.config.js outputs to crates/archivr-server/static/ directly - src/utils.js: formatBytes, valueText, formatTimestamp, SOURCE_ICONS, sourceIconSvg - src/api.js: typed fetch wrappers for all API endpoints - App.jsx: full state management, archive switching, debounced search, tag filter, view routing, capture dialog orchestration - components/Topbar.jsx: archive switcher, nav, capture button - components/CaptureDialog.jsx: native <dialog> ref with showModal/close, Escape key support, locator validation - components/EntriesView.jsx + EntryRow.jsx: flex table with source icons, type pills, keyboard selection - components/ContextRail.jsx: parallel detail+tags fetch, stale-race guard via selectSeqRef, inline tag assign/remove - components/RunsView.jsx: runs kept as <table> (matches original) - components/AdminView.jsx: mounted archives list - components/TagsView.jsx: recursive tag tree with active state - Remove old app.js; styles.css moved to frontend/src/ (Vite bundles it)
This commit is contained in:
parent
b895d6331c
commit
4458f17b13
22 changed files with 1035 additions and 591 deletions
236
frontend/bun.lock
Normal file
236
frontend/bun.lock
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "archivr-frontend",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.11",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.38", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.378", "", {}, "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ=="],
|
||||
|
||||
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.49", "", {}, "sha512-f06bl1D+8ZDkn2oOQQKAh5/otFWqVnM1Q5oerA8Pex7UfT66Tx4IPHIqVVFKqFT3FUtaDstdgkM7yT7JWhqxfw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
}
|
||||
}
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "archivr-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
202
frontend/src/App.jsx
Normal file
202
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags } from './api'
|
||||
import Topbar from './components/Topbar'
|
||||
import CaptureDialog from './components/CaptureDialog'
|
||||
import EntriesView from './components/EntriesView'
|
||||
import RunsView from './components/RunsView'
|
||||
import AdminView from './components/AdminView'
|
||||
import TagsView from './components/TagsView'
|
||||
import ContextRail from './components/ContextRail'
|
||||
|
||||
export default function App() {
|
||||
const [archives, setArchives] = useState([])
|
||||
const [archiveId, setArchiveId] = useState(null)
|
||||
const [entries, setEntries] = useState([])
|
||||
const [selectedEntryUid, setSelectedEntryUid] = useState(null)
|
||||
const [selectedEntry, setSelectedEntry] = useState(null)
|
||||
const [tagFilter, setTagFilter] = useState(null)
|
||||
const [view, setView] = useState('archive')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [resultCount, setResultCount] = useState('')
|
||||
const [searchBusy, setSearchBusy] = useState(false)
|
||||
const [runs, setRuns] = useState([])
|
||||
const [tagNodes, setTagNodes] = useState([])
|
||||
const [captureDialogOpen, setCaptureDialogOpen] = useState(false)
|
||||
|
||||
const loadEntries = useCallback(async (aid, q, tag) => {
|
||||
if (!aid) return
|
||||
setSearchBusy(true)
|
||||
try {
|
||||
let results
|
||||
if (q || tag) {
|
||||
results = await searchEntries(aid, q, tag)
|
||||
} else {
|
||||
results = await fetchEntries(aid)
|
||||
}
|
||||
setEntries(results)
|
||||
setResultCount(results.length === 0 ? 'No results' : `${results.length} result${results.length === 1 ? '' : 's'}`)
|
||||
} catch {
|
||||
setEntries([])
|
||||
setResultCount('Search failed. Try again.')
|
||||
} finally {
|
||||
setSearchBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Mount: load archives, pick first, then parallel load
|
||||
useEffect(() => {
|
||||
fetchArchives().then(list => {
|
||||
setArchives(list)
|
||||
if (list.length > 0) {
|
||||
const first = list[0].id
|
||||
setArchiveId(first)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Archive change: parallel load entries + runs + tags
|
||||
useEffect(() => {
|
||||
if (!archiveId) return
|
||||
setTagFilter(null)
|
||||
setSelectedEntry(null)
|
||||
setSelectedEntryUid(null)
|
||||
Promise.all([
|
||||
loadEntries(archiveId, '', null),
|
||||
fetchRuns(archiveId).then(setRuns),
|
||||
fetchTags(archiveId).then(setTagNodes),
|
||||
])
|
||||
}, [archiveId]) // intentionally not including loadEntries to avoid re-running on its recreation
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (archiveId === null) return
|
||||
const timer = setTimeout(() => {
|
||||
loadEntries(archiveId, searchQuery, tagFilter)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery, archiveId]) // tagFilter handled separately below
|
||||
|
||||
// Tag filter change: switch to archive view, reload
|
||||
useEffect(() => {
|
||||
if (archiveId === null) return
|
||||
setView('archive')
|
||||
loadEntries(archiveId, searchQuery, tagFilter)
|
||||
}, [tagFilter, archiveId]) // intentional: searchQuery excluded to avoid double-fire
|
||||
|
||||
const handleArchiveChange = useCallback((id) => {
|
||||
setArchiveId(id)
|
||||
}, [])
|
||||
|
||||
const handleViewChange = useCallback((name) => {
|
||||
setView(name)
|
||||
if (name === 'tags' && archiveId) {
|
||||
fetchTags(archiveId).then(setTagNodes)
|
||||
}
|
||||
}, [archiveId])
|
||||
|
||||
const selectEntry = useCallback((entry) => {
|
||||
setSelectedEntryUid(entry ? entry.entry_uid : null)
|
||||
setSelectedEntry(entry)
|
||||
}, [])
|
||||
|
||||
const handleTagFilterSet = useCallback((fullPath) => {
|
||||
setTagFilter(fullPath)
|
||||
}, [])
|
||||
|
||||
const handleClearTagFilter = useCallback(() => {
|
||||
setTagFilter(null)
|
||||
}, [])
|
||||
|
||||
const handleTagsRefresh = useCallback(() => {
|
||||
if (archiveId) fetchTags(archiveId).then(setTagNodes)
|
||||
}, [archiveId])
|
||||
|
||||
const handleCaptureClick = useCallback(() => {
|
||||
setCaptureDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleCaptureClose = useCallback(() => {
|
||||
setCaptureDialogOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleCaptured = useCallback(() => {
|
||||
if (!archiveId) return
|
||||
Promise.all([
|
||||
loadEntries(archiveId, searchQuery, tagFilter),
|
||||
fetchRuns(archiveId).then(setRuns),
|
||||
])
|
||||
}, [archiveId, searchQuery, tagFilter, loadEntries])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar
|
||||
archives={archives}
|
||||
archiveId={archiveId}
|
||||
onArchiveChange={handleArchiveChange}
|
||||
view={view}
|
||||
onViewChange={handleViewChange}
|
||||
onCaptureClick={handleCaptureClick}
|
||||
/>
|
||||
<main className="app-shell">
|
||||
<div className="workspace">
|
||||
{view === 'archive' && (
|
||||
<div className="search-row">
|
||||
<input
|
||||
className="search-input"
|
||||
type="search"
|
||||
aria-label="Search archive"
|
||||
aria-busy={searchBusy}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className="result-count">
|
||||
{resultCount}
|
||||
{tagFilter && (
|
||||
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
|
||||
× {tagFilter}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{view === 'archive' && (
|
||||
<EntriesView
|
||||
entries={entries}
|
||||
selectedEntryUid={selectedEntryUid}
|
||||
onSelectEntry={selectEntry}
|
||||
tagFilter={tagFilter}
|
||||
onClearTagFilter={handleClearTagFilter}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
resultCount={resultCount}
|
||||
searchBusy={searchBusy}
|
||||
/>
|
||||
)}
|
||||
{view === 'runs' && <RunsView runs={runs} />}
|
||||
{view === 'admin' && <AdminView archives={archives} />}
|
||||
{view === 'tags' && (
|
||||
<TagsView
|
||||
tagNodes={tagNodes}
|
||||
tagFilter={tagFilter}
|
||||
onTagFilterSet={handleTagFilterSet}
|
||||
onViewChange={handleViewChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ContextRail
|
||||
archiveId={archiveId}
|
||||
selectedEntry={selectedEntry}
|
||||
onTagFilterSet={handleTagFilterSet}
|
||||
tagNodes={tagNodes}
|
||||
onTagsRefresh={handleTagsRefresh}
|
||||
/>
|
||||
</main>
|
||||
<CaptureDialog
|
||||
open={captureDialogOpen}
|
||||
archiveId={archiveId}
|
||||
onClose={handleCaptureClose}
|
||||
onCaptured={handleCaptured}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
70
frontend/src/api.js
Normal file
70
frontend/src/api.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
async function getJson(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchArchives() {
|
||||
return getJson("/api/archives");
|
||||
}
|
||||
|
||||
export async function fetchEntries(archiveId) {
|
||||
return getJson(`/api/archives/${archiveId}/entries`);
|
||||
}
|
||||
|
||||
export async function searchEntries(archiveId, q, tag) {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set("q", q);
|
||||
if (tag) params.set("tag", tag);
|
||||
return getJson(`/api/archives/${archiveId}/entries/search?${params}`);
|
||||
}
|
||||
|
||||
export async function fetchEntryDetail(archiveId, entryUid) {
|
||||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}`);
|
||||
}
|
||||
|
||||
export async function fetchEntryTags(archiveId, entryUid) {
|
||||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/tags`);
|
||||
}
|
||||
|
||||
export async function assignTag(archiveId, entryUid, tagPath) {
|
||||
const resp = await fetch(
|
||||
`/api/archives/${archiveId}/entries/${entryUid}/tags`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tag_path: tagPath }),
|
||||
}
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Failed to add tag (${resp.status})`);
|
||||
}
|
||||
|
||||
export async function removeTag(archiveId, entryUid, tagUid) {
|
||||
const resp = await fetch(
|
||||
`/api/archives/${archiveId}/entries/${entryUid}/tags/${tagUid}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Remove failed (${resp.status})`);
|
||||
}
|
||||
|
||||
export async function fetchRuns(archiveId) {
|
||||
return getJson(`/api/archives/${archiveId}/runs`);
|
||||
}
|
||||
|
||||
export async function fetchTags(archiveId) {
|
||||
return getJson(`/api/archives/${archiveId}/tags`);
|
||||
}
|
||||
|
||||
export async function submitCapture(archiveId, locator) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/captures`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ locator }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
throw new Error(msg || `HTTP ${res.status}`);
|
||||
}
|
||||
}
|
||||
15
frontend/src/components/AdminView.jsx
Normal file
15
frontend/src/components/AdminView.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export default function AdminView({ archives }) {
|
||||
return (
|
||||
<section id="admin-view" className="view admin-view is-active">
|
||||
<h1>Mounted Archives</h1>
|
||||
<div className="admin-list">
|
||||
{archives.map(archive => (
|
||||
<div key={archive.id} className="admin-archive">
|
||||
<strong>{archive.label}</strong>
|
||||
<div className="muted">{archive.archive_path}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
65
frontend/src/components/CaptureDialog.jsx
Normal file
65
frontend/src/components/CaptureDialog.jsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { useRef, useEffect, useState } from 'react'
|
||||
import { submitCapture } from '../api'
|
||||
|
||||
export default function CaptureDialog({ open, archiveId, onClose, onCaptured }) {
|
||||
const dialogRef = useRef(null)
|
||||
const [locator, setLocator] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
const handleClose = () => onClose()
|
||||
dialog.addEventListener('close', handleClose)
|
||||
return () => dialog.removeEventListener('close', handleClose)
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
if (open) {
|
||||
setLocator('')
|
||||
setError(null)
|
||||
if (!dialog.open) dialog.showModal()
|
||||
} else {
|
||||
if (dialog.open) dialog.close()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!locator.trim()) { setError('Enter a locator.'); return }
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await submitCapture(archiveId, locator.trim())
|
||||
dialogRef.current?.close()
|
||||
onCaptured()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="capture-dialog">
|
||||
<div className="capture-dialog-inner">
|
||||
<h2 className="capture-dialog-title">Capture</h2>
|
||||
<label htmlFor="capture-locator" className="capture-label">Locator</label>
|
||||
<input id="capture-locator" className="capture-input" type="text"
|
||||
placeholder="tweet:1234567890 or https://..."
|
||||
value={locator} onChange={e => setLocator(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSubmit() }}
|
||||
autoComplete="off" />
|
||||
{error && <div className="capture-error">{error}</div>}
|
||||
<div className="capture-actions">
|
||||
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>Cancel</button>
|
||||
<button type="button" className="capture-submit" onClick={handleSubmit} disabled={busy}>
|
||||
{busy ? 'Capturing\u2026' : 'Capture'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
163
frontend/src/components/ContextRail.jsx
Normal file
163
frontend/src/components/ContextRail.jsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag } from '../api'
|
||||
import { formatTimestamp, formatBytes, valueText } from '../utils'
|
||||
|
||||
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) {
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [tags, setTags] = useState([])
|
||||
const [assignInput, setAssignInput] = useState('')
|
||||
const [assignError, setAssignError] = useState('')
|
||||
const selectSeqRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEntry || !archiveId) {
|
||||
setDetail(null)
|
||||
setTags([])
|
||||
return
|
||||
}
|
||||
const seq = ++selectSeqRef.current
|
||||
setDetail(null)
|
||||
setTags([])
|
||||
Promise.all([
|
||||
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
|
||||
fetchEntryTags(archiveId, selectedEntry.entry_uid),
|
||||
]).then(([det, tgs]) => {
|
||||
if (seq !== selectSeqRef.current) return
|
||||
setDetail(det)
|
||||
setTags(tgs)
|
||||
}).catch(() => {})
|
||||
}, [selectedEntry, archiveId])
|
||||
|
||||
async function handleAssignTag() {
|
||||
const path = assignInput.trim()
|
||||
if (!path || !selectedEntry) return
|
||||
try {
|
||||
await assignTag(archiveId, selectedEntry.entry_uid, path)
|
||||
setAssignInput('')
|
||||
setAssignError('')
|
||||
const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid)
|
||||
setTags(updated)
|
||||
onTagsRefresh()
|
||||
} catch (e) {
|
||||
setAssignError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveTag(tagUid) {
|
||||
try {
|
||||
await removeTag(archiveId, selectedEntry.entry_uid, tagUid)
|
||||
const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid)
|
||||
setTags(updated)
|
||||
onTagsRefresh()
|
||||
} catch (e) {
|
||||
// silently ignore for now; could add error state
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="context-rail">
|
||||
<div className="rail-title">Context</div>
|
||||
{!selectedEntry ? (
|
||||
<div className="rail-body">Select an entry.</div>
|
||||
) : !detail ? (
|
||||
<div className="rail-body">Loading…</div>
|
||||
) : (
|
||||
<div className="rail-body">
|
||||
<strong className="rail-entry-title">
|
||||
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
|
||||
</strong>
|
||||
|
||||
<div className="rail-section">
|
||||
{detail.summary.original_url && (
|
||||
<div className="rail-item">
|
||||
<span className="rail-label">Original URL</span>:{' '}
|
||||
<a
|
||||
href={detail.summary.original_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rail-url-link"
|
||||
>
|
||||
{detail.summary.original_url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{[
|
||||
['Added', formatTimestamp(detail.summary.archived_at)],
|
||||
['Source', detail.summary.source_kind],
|
||||
['Type', detail.summary.entity_kind],
|
||||
['Visibility', detail.summary.visibility],
|
||||
['Structured root', detail.structured_root_relpath],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rail-item">
|
||||
<span className="rail-label">{label}</span>: {valueText(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{detail.artifacts.length > 0 ? (
|
||||
<div className="rail-section">
|
||||
<div className="rail-section-heading">Artifacts ({detail.artifacts.length})</div>
|
||||
<ul className="artifact-list">
|
||||
{detail.artifacts.map((artifact, index) => (
|
||||
<li key={index}>
|
||||
<a
|
||||
href={`/api/archives/${archiveId}/entries/${detail.summary.entry_uid}/artifacts/${index}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="artifact-link"
|
||||
>
|
||||
{artifact.artifact_role.replace(/_/g, ' ')}
|
||||
{artifact.byte_size != null ? ` (${formatBytes(artifact.byte_size)})` : ''}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rail-item muted">No artifacts.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEntry && (
|
||||
<>
|
||||
<div className="entry-tags">
|
||||
{tags.length === 0 ? (
|
||||
<span className="muted">No tags.</span>
|
||||
) : (
|
||||
tags.map(tag => (
|
||||
<span key={tag.tag_uid} className="tag-pill" title={tag.full_path}>
|
||||
{tag.name}
|
||||
<button
|
||||
className="remove-tag"
|
||||
title={`Remove tag ${tag.full_path}`}
|
||||
onClick={() => handleRemoveTag(tag.tag_uid)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="assign-tag-form">
|
||||
<input
|
||||
className="assign-tag-input"
|
||||
type="text"
|
||||
placeholder="/science/cs"
|
||||
autoComplete="off"
|
||||
value={assignInput}
|
||||
onChange={e => setAssignInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAssignTag() }}
|
||||
/>
|
||||
<button className="assign-tag-btn" onClick={handleAssignTag}>Add tag</button>
|
||||
{assignError && (
|
||||
<div className="muted" style={{ fontSize: '0.85em', color: 'var(--accent)' }}>
|
||||
{assignError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
27
frontend/src/components/EntriesView.jsx
Normal file
27
frontend/src/components/EntriesView.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import EntryRow from './EntryRow';
|
||||
|
||||
export default function EntriesView({ entries, selectedEntryUid, onSelectEntry }) {
|
||||
return (
|
||||
<section id="archive-view" className="view is-active">
|
||||
<div className="entry-table">
|
||||
<div className="entry-header-row">
|
||||
<div className="col-added">Added</div>
|
||||
<div className="col-title">Title</div>
|
||||
<div className="col-type">Type</div>
|
||||
<div className="col-size">Size</div>
|
||||
<div className="col-url">Original URL</div>
|
||||
</div>
|
||||
<div id="entries-body">
|
||||
{entries.map(entry => (
|
||||
<EntryRow
|
||||
key={entry.entry_uid}
|
||||
entry={entry}
|
||||
isSelected={entry.entry_uid === selectedEntryUid}
|
||||
onSelect={() => onSelectEntry(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/EntryRow.jsx
Normal file
24
frontend/src/components/EntryRow.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils';
|
||||
|
||||
export default function EntryRow({ entry, isSelected, onSelect }) {
|
||||
return (
|
||||
<div
|
||||
className={isSelected ? 'is-selected' : undefined}
|
||||
tabIndex={0}
|
||||
data-entry-uid={entry.entry_uid}
|
||||
onClick={onSelect}
|
||||
onKeyDown={e => { if (e.key === 'Enter') onSelect(); }}
|
||||
>
|
||||
<div className="col-added">{formatTimestamp(entry.archived_at)}</div>
|
||||
<div className="col-title">
|
||||
<span className="source-icon" dangerouslySetInnerHTML={{ __html: sourceIconSvg(entry.source_kind) }} />
|
||||
<span className="entry-title">{valueText(entry.title) || valueText(entry.entry_uid)}</span>
|
||||
</div>
|
||||
<div className="col-type">
|
||||
<span className="type-pill">{valueText(entry.entity_kind)}</span>
|
||||
</div>
|
||||
<div className="col-size">{formatBytes(entry.total_artifact_bytes)}</div>
|
||||
<div className="url-cell col-url">{valueText(entry.original_url)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/RunsView.jsx
Normal file
24
frontend/src/components/RunsView.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export default function RunsView({ runs }) {
|
||||
return (
|
||||
<section id="runs-view" className="view is-active">
|
||||
<table className="entry-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Started</th><th>Status</th><th>Requested</th><th>Completed</th><th>Failed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((run, i) => (
|
||||
<tr key={i}>
|
||||
<td>{run.started_at ?? ''}</td>
|
||||
<td>{run.status ?? ''}</td>
|
||||
<td>{run.requested_count ?? ''}</td>
|
||||
<td>{run.completed_count ?? ''}</td>
|
||||
<td>{run.failed_count ?? ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
47
frontend/src/components/TagsView.jsx
Normal file
47
frontend/src/components/TagsView.jsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
function TagNode({ node, tagFilter, onTagFilterSet, onViewChange }) {
|
||||
const isActive = tagFilter === node.tag.full_path
|
||||
function handleClick() {
|
||||
const next = isActive ? null : node.tag.full_path
|
||||
onTagFilterSet(next)
|
||||
onViewChange('archive')
|
||||
}
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
|
||||
title={node.tag.full_path}
|
||||
onClick={handleClick}>
|
||||
{node.tag.name}
|
||||
</button>
|
||||
{node.children?.length > 0 && (
|
||||
<div className="tag-children">
|
||||
<ul className="tag-tree-list">
|
||||
{node.children.map(child => (
|
||||
<TagNode key={child.tag.tag_uid} node={child}
|
||||
tagFilter={tagFilter} onTagFilterSet={onTagFilterSet} onViewChange={onViewChange} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TagsView({ tagNodes, tagFilter, onTagFilterSet, onViewChange }) {
|
||||
return (
|
||||
<section id="tags-view" className="view is-active">
|
||||
<div className="tag-tree">
|
||||
{tagNodes.length === 0 ? (
|
||||
<div>No tags yet.</div>
|
||||
) : (
|
||||
<ul className="tag-tree-list">
|
||||
{tagNodes.map(node => (
|
||||
<TagNode key={node.tag.tag_uid} node={node}
|
||||
tagFilter={tagFilter} onTagFilterSet={onTagFilterSet} onViewChange={onViewChange} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
20
frontend/src/components/Topbar.jsx
Normal file
20
frontend/src/components/Topbar.jsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export default function Topbar({ archives, archiveId, onArchiveChange, view, onViewChange, onCaptureClick }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="brand">Archivr</div>
|
||||
<select className="archive-switcher" aria-label="Select archive"
|
||||
value={archiveId ?? ''} onChange={e => onArchiveChange(e.target.value)}>
|
||||
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||
</select>
|
||||
<nav className="nav" aria-label="Primary">
|
||||
{['archive', 'runs', 'admin', 'tags'].map(name => (
|
||||
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
|
||||
onClick={() => onViewChange(name)}>
|
||||
{name.charAt(0).toUpperCase() + name.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button className="capture-button" onClick={onCaptureClick}>+ Capture</button>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
8
frontend/src/main.jsx
Normal file
8
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './styles.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode><App /></StrictMode>
|
||||
)
|
||||
642
frontend/src/styles.css
Normal file
642
frontend/src/styles.css
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #20251f;
|
||||
--muted: #666a61;
|
||||
--paper: #f5f0e7;
|
||||
--paper-2: #e9e1d2;
|
||||
--paper-3: #fffaf0;
|
||||
--line: #d2c6b5;
|
||||
--line-soft: #e5dccd;
|
||||
--accent: #8d3f30;
|
||||
--accent-2: #b78342;
|
||||
--link: #245f72;
|
||||
--top: #141d18;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 56px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(180px, 250px) 1fr auto;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 18px;
|
||||
background: var(--top);
|
||||
color: #f7eedf;
|
||||
border-bottom: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.archive-switcher {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(247, 238, 223, 0.35);
|
||||
background: #202b25;
|
||||
color: #f7eedf;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #d7cdbf;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.nav-link.is-active {
|
||||
color: #fffaf0;
|
||||
border-bottom: 1px solid #fffaf0;
|
||||
}
|
||||
|
||||
.capture-button {
|
||||
border: 0;
|
||||
background: #f7eedf;
|
||||
color: var(--top);
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: calc(100vh - 56px);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
min-height: 76px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: var(--paper-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
border: 2px solid var(--ink);
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
padding: 0 14px;
|
||||
font-size: 16px;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
min-width: 76px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view.is-active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.entry-table {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.entry-header-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #ded5c7;
|
||||
border-bottom: 1px solid #cec4b5;
|
||||
color: #5d625e;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.entry-header-row > div {
|
||||
padding: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#entries-body > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
#entries-body > div > div {
|
||||
padding: 10px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#entries-body > div:nth-child(even) {
|
||||
background: #f2ede5;
|
||||
}
|
||||
|
||||
#entries-body > div:nth-child(odd) {
|
||||
background: var(--paper-3);
|
||||
}
|
||||
|
||||
#entries-body > div.is-selected {
|
||||
background: #eee2d2;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.col-added {
|
||||
width: 168px;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.col-type {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.col-size {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.col-url {
|
||||
flex: 0 0 30%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Runs table (still a <table>) */
|
||||
#runs-view .entry-table {
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
#runs-view .entry-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
background: #ded5c7;
|
||||
color: #5d625e;
|
||||
border-bottom: 1px solid #cec4b5;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#runs-view .entry-table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
#runs-view .entry-table tr:nth-child(even) td { background: #f2ede5; }
|
||||
#runs-view .entry-table tr:nth-child(odd) td { background: var(--paper-3); }
|
||||
|
||||
.entry-title {
|
||||
color: var(--link);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.source-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-right: 0.35em;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.source-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.url-cell {
|
||||
color: #555b55;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.type-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
background: #d8e3df;
|
||||
color: #275a5f;
|
||||
border: 1px solid #bfd0ca;
|
||||
}
|
||||
|
||||
.context-rail {
|
||||
border-left: 1px solid var(--line);
|
||||
background: #efe7dc;
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rail-title {
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.rail-body {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rail-body strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.rail-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.admin-view {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.admin-view h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.admin-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.admin-archive {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper-3);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topbar {
|
||||
grid-template-columns: 1fr auto;
|
||||
height: auto;
|
||||
min-height: 56px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.archive-switcher,
|
||||
.nav {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.nav {
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-rail {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.entry-table {
|
||||
min-width: 860px;
|
||||
}
|
||||
}
|
||||
|
||||
.rail-entry-title {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rail-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.rail-section-heading {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.rail-label {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.rail-url-link {
|
||||
color: var(--accent);
|
||||
word-break: break-all;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.artifact-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.artifact-link {
|
||||
display: block;
|
||||
padding: 6px 8px;
|
||||
background: var(--paper-3);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.artifact-link:hover {
|
||||
background: var(--line);
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* Search loading state */
|
||||
.search-input[aria-busy="true"] {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* Tag tree */
|
||||
.tag-tree {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tag-tree-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tag-children {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.tag-node-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--link);
|
||||
cursor: pointer;
|
||||
padding: 3px 0;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tag-node-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tag-node-btn.is-active {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Tag pills in context rail */
|
||||
.entry-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--paper-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.remove-tag {
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.remove-tag:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Assign tag form */
|
||||
.assign-tag-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px 0 8px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.assign-tag-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
padding: 4px 7px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assign-tag-btn {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper-2);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.assign-tag-btn:hover {
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
/* Tag filter badge in search row */
|
||||
.tag-filter-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.tag-filter-badge:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Capture dialog */
|
||||
.capture-dialog {
|
||||
border: 2px solid var(--ink);
|
||||
background: var(--paper);
|
||||
padding: 0;
|
||||
min-width: 360px;
|
||||
max-width: 520px;
|
||||
box-shadow: 4px 4px 0 var(--ink);
|
||||
}
|
||||
|
||||
.capture-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.capture-dialog-inner {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.capture-dialog-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
.capture-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.capture-input {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
border: 2px solid var(--ink);
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
margin-bottom: 10px;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.capture-error {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.capture-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.capture-cancel {
|
||||
border: 1px solid var(--line);
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.capture-cancel:hover {
|
||||
background: var(--paper-2);
|
||||
}
|
||||
|
||||
.capture-submit {
|
||||
border: 0;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.capture-submit:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.capture-submit:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
40
frontend/src/utils.js
Normal file
40
frontend/src/utils.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
export function formatBytes(bytes) {
|
||||
if (!bytes) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = bytes;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function valueText(value) {
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
export function formatTimestamp(value) {
|
||||
if (!value) return "";
|
||||
const d = new Date(value);
|
||||
if (isNaN(d)) return value;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
export const SOURCE_ICONS = {
|
||||
youtube: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#FF0000" d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2C0 8.1 0 12 0 12s0 3.9.5 5.8a3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1C24 15.9 24 12 24 12s0-3.9-.5-5.8z"/><polygon fill="#fff" points="9.6,15.6 15.8,12 9.6,8.4"/></svg>`,
|
||||
x: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.2 2h3.3l-7.2 8.2L23 22h-6.6l-5.2-6.8L5 22H1.7l7.7-8.8L1 2h6.8l4.7 6.2zm-1.1 18h1.8L6.9 3.9H5z"/></svg>`,
|
||||
instagram: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ig" x1="0%" y1="100%" x2="100%" y2="0%"><stop offset="0%" stop-color="#f09433"/><stop offset="25%" stop-color="#e6683c"/><stop offset="50%" stop-color="#dc2743"/><stop offset="75%" stop-color="#cc2366"/><stop offset="100%" stop-color="#bc1888"/></linearGradient></defs><rect width="24" height="24" rx="5" fill="url(#ig)"/><rect x="2.5" y="2.5" width="19" height="19" rx="4" fill="none" stroke="#fff" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="#fff" stroke-width="1.5"/><circle cx="17.5" cy="6.5" r="1" fill="#fff"/></svg>`,
|
||||
facebook: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="24" rx="4" fill="#1877F2"/><path fill="#fff" d="M16 8h-2c-.6 0-1 .4-1 1v2h3l-.4 3H13v8h-3v-8H8v-3h2V9a4 4 0 0 1 4-4h2v3z"/></svg>`,
|
||||
tiktok: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.6 5.4a4.8 4.8 0 0 1-4.8-4.8h-3v14.7a2 2 0 1 1-2-2l.1-3a5 5 0 1 0 4.9 5V8.4a8 8 0 0 0 4.8 1.6V6.7a4.8 4.8 0 0 1-0-.2l0 0 .1.9z" fill="#000"/><path d="M18.6 4.4a4.8 4.8 0 0 1-4.8-4.8h-3v14.7a2 2 0 1 1-2-2l.1-3a5 5 0 1 0 4.9 5V7.4a8 8 0 0 0 4.8 1.6V5.7" fill="none" stroke="#69C9D0" stroke-width="1.5"/></svg>`,
|
||||
reddit: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="12" fill="#FF4500"/><path fill="#fff" d="M20 12a2 2 0 0 0-2-2 2 2 0 0 0-1.3.5c-1.3-.9-3-.9-4.6-.9l.8-3.7 2.6.5a1.4 1.4 0 1 0 1.4-1.3 1.4 1.4 0 0 0-1.3.8l-2.9-.5a.3.3 0 0 0-.4.3l-.9 4.1c-1.6 0-3.1.1-4.3.9A2 2 0 0 0 4 12a2 2 0 0 0 1 1.7 3.3 3.3 0 0 0 0 .5c0 2.6 3 4.8 6.8 4.8s6.8-2.1 6.8-4.8a3.3 3.3 0 0 0 0-.5A2 2 0 0 0 20 12zm-13.6 1a1.1 1.1 0 1 1 1.1 1.1A1.1 1.1 0 0 1 6.4 13zm6.2 3.1a3.5 3.5 0 0 1-2.3.7 3.5 3.5 0 0 1-2.3-.7.3.3 0 0 1 .4-.4 3 3 0 0 0 1.9.5 3 3 0 0 0 1.9-.5.3.3 0 0 1 .4.4zm-.3-2a1.1 1.1 0 1 1 1.1-1.1A1.1 1.1 0 0 1 12.3 14.1z"/></svg>`,
|
||||
snapchat: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="24" rx="4" fill="#FFFC00"/><path d="M12 3.5c-2.4 0-4.4 2-4.4 4.4v1.3l-.8.1c-.3 0-.5.2-.5.5s.3.5.6.6c.3.1.5.3.5.8 0 .3-.1.5-.3.7-.6.8-1.5 1.3-2.2 1.4-.1.7.7 1 1.7 1.2.1.6.3.8.8.8.6 0 1.1.4 2.7.4 1.5 0 2.1-.4 2.7-.4.5 0 .7-.2.8-.8 1-.2 1.8-.5 1.7-1.2-.7-.1-1.6-.6-2.2-1.4-.2-.2-.3-.4-.3-.7 0-.5.2-.7.5-.8.3-.1.6-.3.6-.6s-.2-.5-.5-.5l-.8-.1V7.9C16.4 5.5 14.4 3.5 12 3.5z"/></svg>`,
|
||||
local: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="#666" stroke-width="1.5" stroke-linejoin="round" d="M4 4h7l2 2h7v14H4z"/></svg>`,
|
||||
web: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="none" stroke="#555" stroke-width="1.5"/><ellipse cx="12" cy="12" rx="4" ry="10" fill="none" stroke="#555" stroke-width="1.5"/><line x1="2" y1="9" x2="22" y2="9" stroke="#555" stroke-width="1.5"/><line x1="2" y1="15" x2="22" y2="15" stroke="#555" stroke-width="1.5"/></svg>`,
|
||||
other: `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="none" stroke="#999" stroke-width="1.5"/><text x="12" y="16" text-anchor="middle" font-size="12" fill="#999">?</text></svg>`,
|
||||
};
|
||||
|
||||
export function sourceIconSvg(kind) {
|
||||
return SOURCE_ICONS[kind] ?? SOURCE_ICONS.other;
|
||||
}
|
||||
11
frontend/vite.config.js
Normal file
11
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "../crates/archivr-server/static",
|
||||
emptyOutDir: false,
|
||||
assetsDir: "assets",
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue