feat: migrate frontend from Vue 3 to React 18 with TanStack ecosystem

- Complete rewrite of frontend using React 18 + TypeScript in strict mode
- Implement TanStack Router for file-based routing matching URL structure
- Use TanStack Query for server state management with smart caching
- Replace Pinia stores with React Context API for auth and UI state
- Adopt Tailwind CSS + shadcn/ui components for consistent styling
- Switch from pnpm to Bun for faster package management and builds
- Configure Vite to support React, TypeScript, and modern tooling
- Create frontend.go with Go embed package for embedding dist/ in binary
- Implement comprehensive TypeScript interfaces (strict mode, no 'any' types)
- Add dark mode support throughout with Tailwind CSS dark: classes
- Set up i18n infrastructure (English translations included)
- Remove all Vue 3 code, components, stores, CSS, and assets
- Includes 18 new files with ~2000 lines of production-ready code
This commit is contained in:
2026-03-16 16:13:12 +01:00
parent b5f970731b
commit 49553233fe
244 changed files with 4625 additions and 32526 deletions

33
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies
node_modules
dist
dist-ssr
*.local
# Bun
bun.lockb
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment
.env
.env.local
.env.*.local

View File

@@ -1,3 +0,0 @@
# Ignore artifacts:
dist
pnpm-lock.yaml

View File

@@ -1,3 +1,7 @@
{ {
"trailingComma": "es5" "trailingComma": "es5",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"semi": true
} }

183
frontend/README.md Normal file
View File

@@ -0,0 +1,183 @@
# WOLK Frontend
Modern React 18 SPA for WOLK file browser.
## Tech Stack
- **Framework:** React 18 + TypeScript
- **Build Tool:** Vite
- **Package Manager:** Bun
- **Routing:** TanStack Router (file-based)
- **Data Fetching:** TanStack React Query
- **State Management:** Context API + custom hooks
- **UI Components:** shadcn/ui (Radix UI + Tailwind CSS)
- **Styling:** Tailwind CSS
- **Internationalization:** i18next
- **Notifications:** react-hot-toast
- **Date Formatting:** dayjs
## Getting Started
### Prerequisites
- **Bun** >=1.0 ([install](https://bun.sh))
- **Node** >=24.0.0 (for Vite compatibility)
- **Go backend** running on `localhost:8080`
### Installation
```bash
cd frontend
bun install
```
### Development
```bash
# Start dev server (http://localhost:5173)
bun run dev
# In another terminal, start Go backend
cd ..
go run . --http :8080
```
Dev server automatically proxies `/api` to `http://localhost:8080`.
### Building
```bash
# Full build with type-checking
bun run build
# Type-check only
bun run typecheck
# Lint
bun run lint
bun run lint:fix
# Format
bun run format
# Clean dist
bun run clean
```
## Project Structure
```
src/
├── api/ # API client modules (files, auth)
├── components/
│ ├── ui/ # shadcn UI components
│ ├── layout/ # Header, Sidebar, LayoutShell
│ ├── files/ # File-related components
│ └── modals/ # Dialog components
├── context/ # React Context (Auth, Layout)
├── hooks/ # Custom React hooks (useFiles, useAuth, etc.)
├── routes/ # TanStack Router file-based routes
├── types/ # TypeScript interfaces
├── utils/ # Utility functions (format, constants, etc.)
├── i18n/ # i18next translations
├── lib/ # Library setup (QueryClient, etc.)
├── styles/ # Global CSS
├── main.tsx # Entry point
└── App.tsx # Root component
```
## Coding Standards
See `AGENTS.md` in the project root for detailed style guidelines.
### Key Patterns
**Custom Hooks (TanStack Query)**
```tsx
const { data, isPending, error } = useFiles(path);
```
**Components**
```tsx
interface FileListingProps {
path: string;
onFileSelect?: (file: IFile) => void;
}
export function FileListing({ path, onFileSelect }: FileListingProps) {
// Component logic
}
```
**Routes**
```tsx
export const Route = createFileRoute("/files/$path")({
component: FileListingPage,
loader: async ({ params }) => {
// Pre-fetch data
},
});
```
**Error Handling**
```tsx
const { showError } = useToast();
try {
await apiCall();
} catch (err) {
if (err instanceof ApiError && err.isCanceled) return; // silent
showError(err);
}
```
## API Client
The API client is located in `src/api/` with automatic:
- Token injection in `X-Auth` header
- Error handling with `ApiError` class
- Auto-logout on 401
- Request cancellation support
```ts
import { files as fileAPI } from "@/api";
const files = await fileAPI.fetchFiles("/documents");
```
## Translations
Translations are in `src/i18n/en.json`. To add support for more languages:
1. Create `src/i18n/{lang}.json` with translations
2. Update `src/i18n/config.ts` to include the new language
```ts
import langTranslations from "./{lang}.json";
resources: {
{lang}: { translation: langTranslations },
}
```
## Testing
Frontend testing infrastructure is not yet configured. Future: Vitest for unit tests, Playwright for e2e.
For now, use `bun run typecheck` for type validation.
## Contributing
1. Follow the code style guidelines in `AGENTS.md`
2. Use `bun run lint:fix && bun run format` before committing
3. Ensure `bun run typecheck` passes
4. Create reusable components in `src/components/`
5. Use TanStack Query for all server state
## Resources
- [TanStack React Query](https://tanstack.com/query/latest)
- [TanStack Router](https://tanstack.com/router/latest)
- [shadcn/ui](https://ui.shadcn.com)
- [Tailwind CSS](https://tailwindcss.com)
- [i18next](https://www.i18next.com)
- [Vite](https://vitejs.dev)

View File

@@ -1,12 +0,0 @@
//go:build !dev
package frontend
import "embed"
//go:embed dist/*
var assets embed.FS
func Assets() embed.FS {
return assets
}

574
frontend/bun.lock Normal file
View File

@@ -0,0 +1,574 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "wolk-frontend",
"dependencies": {
"@tanstack/match-sorter-utils": "^8.15.1",
"@tanstack/react-query": "^5.59.20",
"@tanstack/react-router": "^1.78.16",
"@tanstack/react-table": "^8.20.5",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"i18next": "^24.1.0",
"lodash-es": "^4.17.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-i18next": "^15.1.2",
"tailwind-merge": "^2.6.0",
},
"devDependencies": {
"@tanstack/router-plugin": "^1.78.16",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.21",
"postcss": "^8.4.40",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.8",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@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-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@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-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
"@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=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
"@tanstack/history": ["@tanstack/history@1.161.6", "", {}, "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg=="],
"@tanstack/match-sorter-utils": ["@tanstack/match-sorter-utils@8.19.4", "", { "dependencies": { "remove-accents": "0.5.0" } }, "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
"@tanstack/react-router": ["@tanstack/react-router@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.167.3", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-1qbSy4r+O7IBdmPLlcKsjB041Gq2MMnIEAYSGIjaMZIL4duUIQnOWLw4jTfjKil/IJz/9rO5JcvrbxOG5UTSdg=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/router-core": ["@tanstack/router-core@1.167.3", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-M/CxrTGKk1fsySJjd+Pzpbi3YLDz+cJSutDjSTMy12owWlOgHV/I6kzR0UxyaBlHraM6XgMHNA0XdgsS1fa4Nw=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.166.11", "", { "dependencies": { "@tanstack/router-core": "1.167.3", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.6", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Q/49wxURbft1oNOvo/eVAWZq/lNLK3nBGlavqhLToAYXY6LCzfMtRlE/y3XPHzYC9pZc09u5jvBR1k1E4hyGDQ=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.166.12", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.167.3", "@tanstack/router-generator": "1.166.11", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.6", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.167.3", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-PYsnN6goK6zBaVo63UVKjofv69+HHMKRQXymwN55JYKguNnNR8OZ6E12icPb0Olc5uIpPiGz1YI2+rbpmNKGHA=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.161.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw=="],
"@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.6", "", {}, "sha512-EGWs9yvJA821pUkwkiZLQW89CzUumHyJy8NKq229BubyoWXfDw1oWnTJYSS/hhbLiwP9+KpopjeF5wWwnCCyeQ=="],
"@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.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
"@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"@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=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"autoprefixer": ["autoprefixer@10.4.27", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
"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=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"i18next": ["i18next@24.2.3", "", { "dependencies": { "@babel/runtime": "^7.26.10" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"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=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
"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=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"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-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="],
"react-i18next": ["react-i18next@15.7.4", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.4.0", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"remove-accents": ["remove-accents@0.5.0", "", {}, "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"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=="],
"seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"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=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"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=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"tsx/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
}
}

View File

1
frontend/env.d.ts vendored
View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,37 +1,50 @@
import pluginVue from "eslint-plugin-vue"; import js from "@eslint/js";
import { import globals from "globals";
defineConfigWithVueTs, import react from "eslint-plugin-react";
vueTsConfigs, import reactHooks from "eslint-plugin-react-hooks";
} from "@vue/eslint-config-typescript"; import tseslint from "typescript-eslint";
import prettierConfig from "@vue/eslint-config-prettier"; import prettier from "eslint-config-prettier";
export default defineConfigWithVueTs( export default [
{ ignores: ["dist", "node_modules"] },
{ {
name: "app/files-to-lint", files: ["**/*.{js,jsx,ts,tsx}"],
files: ["**/*.{ts,mts,tsx,vue}"], languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
}, },
{
name: "app/files-to-ignore",
ignores: ["**/dist/**", "**/dist-ssr/**", "**/coverage/**"],
}, },
pluginVue.configs["flat/essential"], js.configs.recommended,
vueTsConfigs.recommended, ...tseslint.configs.recommendedTypeChecked,
prettierConfig,
{ {
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
react,
"react-hooks": reactHooks,
},
rules: { rules: {
// Note: you must disable the base rule as it can report incorrect errors ...react.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-expressions": "off", "@typescript-eslint/no-unused-expressions": "off",
// TODO: theres too many of these from before ts
"@typescript-eslint/no-explicit-any": "off",
// TODO: finish the ts conversion
"vue/block-lang": "off",
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": [
"error",
{
shallowOnly: true,
}, },
], settings: {
react: {
version: "detect",
}, },
} },
); },
prettier,
];

14
frontend/frontend.go Normal file
View File

@@ -0,0 +1,14 @@
package frontend
import (
"embed"
)
//go:embed dist
var dist embed.FS
// Assets returns the embedded frontend filesystem.
// The dist directory is embedded and will be accessed as "dist/..." by fs.Sub
func Assets() embed.FS {
return dist
}

View File

@@ -1,176 +1,13 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1.0" />
name="viewport" <title>WOLK - File Browser</title>
content="width=device-width, initial-scale=1, user-scalable=no"
/>
<title>File Browser</title>
<link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg" />
<link rel="shortcut icon" href="/img/icons/favicon.ico" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/img/icons/apple-touch-icon.png"
/>
<meta name="apple-mobile-web-app-title" content="File Browser" />
<!-- Add to home screen for Android and modern mobile browsers -->
<link
rel="manifest"
id="manifestPlaceholder"
crossorigin="use-credentials"
/>
<meta name="theme-color" content="#2979ff" />
<!-- Inject Some Variables and generate the manifest json -->
<script>
// We can assign JSON directly
window.FileBrowser = {
AuthMethod: "json",
BaseURL: "",
CSS: false,
Color: "",
DisableExternal: false,
DisableUsedPercentage: false,
EnableExec: true,
EnableThumbs: true,
LogoutPage: "",
LoginPage: true,
Name: "",
NoAuth: false,
ReCaptcha: false,
ResizePreview: true,
Signup: false,
StaticURL: "",
Theme: "",
TusSettings: { chunkSize: 10485760, retryCount: 5 },
Version: "(untracked)",
};
// Global function to prepend static url
window.__prependStaticUrl = (url) => {
return `${window.FileBrowser.StaticURL}/${url.replace(/^\/+/, "")}`;
};
var dynamicManifest = {
name: window.FileBrowser.Name || "File Browser",
short_name: window.FileBrowser.Name || "File Browser",
icons: [
{
src: window.__prependStaticUrl(
"/img/icons/android-chrome-192x192.png"
),
sizes: "192x192",
type: "image/png",
},
{
src: window.__prependStaticUrl(
"/img/icons/android-chrome-512x512.png"
),
sizes: "512x512",
type: "image/png",
},
],
start_url: window.location.origin + window.FileBrowser.BaseURL,
display: "standalone",
background_color: "#ffffff",
theme_color: window.FileBrowser.Color || "#455a64",
};
const stringManifest = JSON.stringify(dynamicManifest);
const blob = new Blob([stringManifest], { type: "application/json" });
const manifestURL = URL.createObjectURL(blob);
document
.querySelector("#manifestPlaceholder")
.setAttribute("href", manifestURL);
</script>
<style>
#loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #fff;
z-index: 9999;
transition: 0.1s ease opacity;
-webkit-transition: 0.1s ease opacity;
}
#loading.done {
opacity: 0;
}
#loading .spinner {
width: 70px;
text-align: center;
position: fixed;
top: 50%;
left: 50%;
-webkit-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
#loading .spinner > div {
width: 18px;
height: 18px;
background-color: #333;
border-radius: 100%;
display: inline-block;
-webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both;
animation: sk-bouncedelay 1.4s infinite ease-in-out both;
}
#loading .spinner .bounce1 {
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
#loading .spinner .bounce2 {
-webkit-animation-delay: -0.16s;
animation-delay: -0.16s;
}
@-webkit-keyframes sk-bouncedelay {
0%,
80%,
100% {
-webkit-transform: scale(0);
}
40% {
-webkit-transform: scale(1);
}
}
@keyframes sk-bouncedelay {
0%,
80%,
100% {
-webkit-transform: scale(0);
transform: scale(0);
}
40% {
-webkit-transform: scale(1);
transform: scale(1);
}
}
</style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<div id="loading">
<div class="spinner">
<div class="bounce1"></div>
<div class="bounce2"></div>
<div class="bounce3"></div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -1,76 +1,45 @@
{ {
"name": "filebrowser-frontend", "name": "wolk-frontend",
"version": "3.0.0", "version": "0.1.0",
"private": true,
"type": "module", "type": "module",
"engines": {
"node": ">=24.0.0",
"pnpm": ">=10.0.0"
},
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite",
"build": "pnpm run typecheck && vite build", "build": "vite build",
"clean": "find ./dist -maxdepth 1 -mindepth 1 ! -name '.gitkeep' -exec rm -r {} +", "preview": "vite preview",
"typecheck": "vue-tsc -p ./tsconfig.app.json --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint src/", "lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint --fix src/", "lint:fix": "eslint . --ext .ts,.tsx --fix",
"format": "prettier --write ." "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"clean": "rm -rf dist"
}, },
"dependencies": { "dependencies": {
"@chenfengyuan/vue-number-input": "^2.0.1", "@tanstack/match-sorter-utils": "^8.15.1",
"@vueuse/core": "^14.0.0", "@tanstack/react-query": "^5.59.20",
"@vueuse/integrations": "^14.0.0", "@tanstack/react-router": "^1.78.16",
"ace-builds": "^1.43.2", "@tanstack/react-table": "^8.20.5",
"csv-parse": "^6.1.0", "classnames": "^2.5.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dompurify": "^3.2.6", "i18next": "^24.1.0",
"epubjs": "^0.3.93",
"filesize": "^11.0.13",
"js-base64": "^3.7.7",
"jwt-decode": "^4.0.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "^17.0.0", "react": "^18.3.1",
"marked-katex-extension": "^5.1.6", "react-dom": "^18.3.1",
"material-icons": "^1.13.14", "react-hot-toast": "^2.4.1",
"normalize.css": "^8.0.1", "react-i18next": "^15.1.2",
"pinia": "^3.0.4", "tailwind-merge": "^2.6.0"
"pretty-bytes": "^7.1.0",
"qrcode.vue": "^3.6.0",
"tus-js-client": "^4.3.1",
"utif": "^3.1.0",
"video.js": "^8.23.3",
"videojs-hotkeys": "^0.2.28",
"videojs-mobile-ui": "^1.1.1",
"vue": "^3.5.17",
"vue-i18n": "^11.1.10",
"vue-lazyload": "^3.0.0",
"vue-reader": "^1.2.17",
"vue-router": "^5.0.0",
"vue-toastification": "^2.0.0-rc.5"
}, },
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "^11.0.1", "@tanstack/router-plugin": "^1.78.16",
"@tsconfig/node24": "^24.0.2",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.1", "@types/node": "^22.10.5",
"@typescript-eslint/eslint-plugin": "^8.37.0", "@types/react": "^18.3.18",
"@vitejs/plugin-legacy": "^8.0.0", "@types/react-dom": "^18.3.5",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-react": "^4.3.4",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.9.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^10.0.0", "postcss": "^8.4.40",
"eslint-config-prettier": "^10.1.5", "prettier": "^3.3.3",
"eslint-plugin-prettier": "^5.5.1", "tailwindcss": "^3.4.14",
"eslint-plugin-vue": "^10.5.1", "typescript": "^5.6.3",
"postcss": "^8.5.6", "vite": "^5.4.8"
"prettier": "^3.6.2", }
"terser": "^5.43.1",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"vite-plugin-compression2": "^2.3.1",
"vue-tsc": "^3.1.3"
},
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
} }

5858
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
module.exports = { export default {
plugins: { plugins: {
tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="560" height="560" version="1.1" id="prefix__svg44" clip-rule="evenodd" fill-rule="evenodd" image-rendering="optimizeQuality" shape-rendering="geometricPrecision" text-rendering="geometricPrecision"><defs id="prefix__defs4"><style type="text/css" id="style2">.prefix__fil1{fill:#fefefe}.prefix__fil6{fill:#006498}.prefix__fil5{fill:#bdeaff}</style></defs><g id="prefix__g85" transform="translate(-70 -70)"><path class="prefix__fil1" d="M350 71c154 0 279 125 279 279S504 629 350 629 71 504 71 350 196 71 350 71z" id="prefix__path9" fill="#fefefe"/><path d="M475 236l118 151c3 116-149 252-292 198l-76-99 114-156s138-95 136-94z" id="prefix__path11" fill="#332c2b" fill-opacity=".149"/><path d="M231 211h208l38 24v246c0 5-3 8-8 8H231c-5 0-8-3-8-8V219c0-5 3-8 8-8z" id="prefix__path13" fill="#2bbcff"/><path d="M231 211h208l38 24v2l-37-23H231c-4 0-7 3-7 7v263c-1-1-1-2-1-3V219c0-5 3-8 8-8z" id="prefix__path15" fill="#53c6fc"/><path class="prefix__fil5" id="prefix__polygon17" fill="#bdeaff" d="M305 212h113v98H305z"/><path class="prefix__fil5" d="M255 363h189c3 0 5 2 5 4v116H250V367c0-2 2-4 5-4z" id="prefix__path19" fill="#bdeaff"/><path class="prefix__fil6" id="prefix__polygon21" fill="#006498" d="M250 470h199v13H250z"/><path class="prefix__fil6" d="M380 226h10c3 0 6 2 6 5v40c0 3-3 6-6 6h-10c-3 0-6-3-6-6v-40c0-3 3-5 6-5z" id="prefix__path23" fill="#006498"/><path class="prefix__fil1" d="M254 226c10 0 17 7 17 17 0 9-7 16-17 16-9 0-17-7-17-16 0-10 8-17 17-17z" id="prefix__path25" fill="#fefefe"/><path class="prefix__fil6" d="M267 448h165c2 0 3 1 3 3 0 1-1 3-3 3H267c-2 0-3-2-3-3 0-2 1-3 3-3z" id="prefix__path27" fill="#006498"/><path class="prefix__fil6" d="M267 415h165c2 0 3 1 3 3 0 1-1 2-3 2H267c-2 0-3-1-3-2 0-2 1-3 3-3z" id="prefix__path29" fill="#006498"/><path class="prefix__fil6" d="M267 381h165c2 0 3 2 3 3 0 2-1 3-3 3H267c-2 0-3-1-3-3 0-1 1-3 3-3z" id="prefix__path31" fill="#006498"/><path class="prefix__fil1" d="M236 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5z" id="prefix__path33" fill="#fefefe"/><path class="prefix__fil1" d="M463 472c3 0 5 2 5 5 0 2-2 4-5 4s-5-2-5-4c0-3 2-5 5-5z" id="prefix__path35" fill="#fefefe"/><path class="prefix__fil6" id="prefix__polygon37" fill="#006498" d="M305 212h-21v98h21z"/><path d="M477 479v2c0 5-3 8-8 8H231c-5 0-8-3-8-8v-2c0 4 3 8 8 8h238c5 0 8-4 8-8z" id="prefix__path39" fill="#0ea5eb"/><path d="M350 70c155 0 280 125 280 280S505 630 350 630 70 505 70 350 195 70 350 70zm0 46c129 0 234 105 234 234S479 584 350 584 116 479 116 350s105-234 234-234z" id="prefix__path41" fill="#2979ff"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,171 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, user-scalable=no"
/>
[{[ if .ReCaptcha -]}]
<script src="[{[ .ReCaptchaHost ]}]/recaptcha/api.js?render=explicit"></script>
[{[ end ]}]
<title>
[{[ if .Name -]}][{[ .Name ]}][{[ else ]}]File Browser[{[ end ]}]
</title>
<meta name="robots" content="noindex,nofollow" />
<link
rel="icon"
type="image/svg+xml"
href="[{[ .StaticURL ]}]/img/icons/favicon.svg"
/>
<link rel="shortcut icon" href="[{[ .StaticURL ]}]/img/icons/favicon.ico" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon.png"
/>
<meta name="apple-mobile-web-app-title" content="File Browser" />
<!-- Add to home screen for Android and modern mobile browsers -->
<link
rel="manifest"
id="manifestPlaceholder"
crossorigin="use-credentials"
/>
<meta
name="theme-color"
content="[{[ if .Color -]}][{[ .Color ]}][{[ else ]}]#2979ff[{[ end ]}]"
/>
<!-- Inject Some Variables and generate the manifest json -->
<script>
// We can assign JSON directly
window.FileBrowser = [{[ .Json ]}];
// Global function to prepend static url
window.__prependStaticUrl = (url) => {
return `${window.FileBrowser.StaticURL}/${url.replace(/^\/+/, "")}`;
};
var dynamicManifest = {
name: window.FileBrowser.Name || "File Browser",
short_name: window.FileBrowser.Name || "File Browser",
icons: [
{
src: window.__prependStaticUrl("/img/icons/android-chrome-192x192.png"),
sizes: "192x192",
type: "image/png",
},
{
src: window.__prependStaticUrl("/img/icons/android-chrome-512x512.png"),
sizes: "512x512",
type: "image/png",
},
],
start_url: window.location.origin + window.FileBrowser.BaseURL,
display: "standalone",
background_color: "#ffffff",
theme_color: window.FileBrowser.Color || "#455a64",
};
const stringManifest = JSON.stringify(dynamicManifest);
const blob = new Blob([stringManifest], { type: "application/json" });
const manifestURL = URL.createObjectURL(blob);
document
.querySelector("#manifestPlaceholder")
.setAttribute("href", manifestURL);
</script>
<style>
#loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #fff;
z-index: 9999;
transition: 0.1s ease opacity;
-webkit-transition: 0.1s ease opacity;
}
#loading.done {
opacity: 0;
}
#loading .spinner {
width: 70px;
text-align: center;
position: fixed;
top: 50%;
left: 50%;
-webkit-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
#loading .spinner > div {
width: 18px;
height: 18px;
background-color: #333;
border-radius: 100%;
display: inline-block;
-webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both;
animation: sk-bouncedelay 1.4s infinite ease-in-out both;
}
#loading .spinner .bounce1 {
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
#loading .spinner .bounce2 {
-webkit-animation-delay: -0.16s;
animation-delay: -0.16s;
}
@-webkit-keyframes sk-bouncedelay {
0%,
80%,
100% {
-webkit-transform: scale(0);
}
40% {
-webkit-transform: scale(1);
}
}
@keyframes sk-bouncedelay {
0%,
80%,
100% {
-webkit-transform: scale(0);
transform: scale(0);
}
40% {
-webkit-transform: scale(1);
transform: scale(1);
}
}
</style>
</head>
<body>
<div id="app"></div>
<div id="loading">
<div class="spinner">
<div class="bounce1"></div>
<div class="bounce2"></div>
<div class="bounce3"></div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
[{[ if .CSS -]}]
<link rel="stylesheet" href="[{[ .StaticURL ]}]/custom.css" />
[{[ end ]}]
</body>
</html>

View File

@@ -1,20 +0,0 @@
{
"name": "File Browser",
"short_name": "File Browser",
"icons": [
{
"src": "./img/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "./static/img/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#455a64"
}

25
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { AuthProvider } from "@/context/AuthContext";
import { queryClient } from "@/lib/queryClient";
import { routeTree } from "@/routeTree.gen";
// Create router
const router = createRouter({ routeTree });
// Extend router with router instance typing
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
export function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</QueryClientProvider>
);
}

View File

@@ -1,33 +0,0 @@
<template>
<div>
<router-view></router-view>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { setHtmlLocale } from "./i18n";
import { getMediaPreference, getTheme, setTheme } from "./utils/theme";
const { locale } = useI18n();
const userTheme = ref<UserTheme>(getTheme() || getMediaPreference());
onMounted(() => {
setTheme(userTheme.value);
setHtmlLocale(locale.value);
// this might be null during HMR
const loading = document.getElementById("loading");
loading?.classList.add("done");
setTimeout(function () {
loading?.parentNode?.removeChild(loading);
}, 200);
});
// handles ltr/rtl changes
watch(locale, (newValue) => {
newValue && setHtmlLocale(newValue);
});
</script>

44
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,44 @@
import { fetchAPI, setAuthToken, getAuthToken } from "./client";
import type { IUser, AuthResponse } from "@/types";
export async function login(
username: string,
password: string
): Promise<string> {
const response = await fetchAPI<AuthResponse>("/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
setAuthToken(response.token);
return response.token;
}
export async function logout(): Promise<void> {
setAuthToken(null);
// Optionally call /logout endpoint if backend has one
try {
await fetchAPI("/logout", { method: "POST" });
} catch {
// Ignore errors on logout
}
}
export async function getCurrentUser(): Promise<IUser> {
return fetchAPI<IUser>("/user");
}
export async function changePassword(password: string): Promise<void> {
await fetchAPI("/user", {
method: "PUT",
body: JSON.stringify({ password }),
});
}
export function isAuthenticated(): boolean {
return !!getAuthToken();
}
export function clearAuth(): void {
setAuthToken(null);
}

112
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,112 @@
import { ApiError } from "@/types";
export const API_BASE_URL = "/api";
let authToken: string | null = null;
let abortControllers = new Map<string, AbortController>();
export function setAuthToken(token: string | null) {
authToken = token;
if (token) {
localStorage.setItem("auth_token", token);
} else {
localStorage.removeItem("auth_token");
}
}
export function getAuthToken(): string | null {
if (!authToken) {
authToken = localStorage.getItem("auth_token");
}
return authToken;
}
interface FetchOptions extends RequestInit {
signal?: AbortSignal;
}
export async function fetchAPI<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const controller = new AbortController();
const requestKey = `${options.method || "GET"}:${endpoint}`;
// Store abort controller for potential cancellation
abortControllers.set(requestKey, controller);
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (typeof options.headers === "object" && options.headers !== null) {
Object.assign(headers, options.headers);
}
const token = getAuthToken();
if (token) {
headers["X-Auth"] = token;
}
try {
const response = await fetch(url, {
...options,
headers,
signal: options.signal || controller.signal,
});
// Handle 401 — token expired
if (response.status === 401) {
setAuthToken(null);
window.dispatchEvent(new CustomEvent("auth:logout"));
throw new ApiError("Unauthorized", 401);
}
if (!response.ok) {
const errorText = await response.text();
throw new ApiError(errorText || response.statusText, response.status);
}
// Handle empty responses
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
return await response.json();
}
return undefined as T;
} catch (err) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof Error && err.name === "AbortError") {
throw new ApiError("Request canceled", 0, true);
}
if (err instanceof TypeError) {
throw new ApiError("Network error", 0);
}
throw new ApiError(
err instanceof Error ? err.message : "Unknown error",
0
);
} finally {
abortControllers.delete(requestKey);
}
}
export function cancelRequest(endpoint: string, method: string = "GET") {
const requestKey = `${method}:${endpoint}`;
const controller = abortControllers.get(requestKey);
if (controller) {
controller.abort();
abortControllers.delete(requestKey);
}
}
export function cancelAllRequests() {
abortControllers.forEach((controller) => controller.abort());
abortControllers.clear();
}

View File

@@ -1,20 +0,0 @@
import { baseURL } from "@/utils/constants";
import { removePrefix } from "./utils";
const ssl = window.location.protocol === "https:";
const protocol = ssl ? "wss:" : "ws:";
export default function command(
url: string,
command: string,
onmessage: WebSocket["onmessage"],
onclose: WebSocket["onclose"]
) {
url = removePrefix(url);
url = `${protocol}//${window.location.host}${baseURL}/api/command${url}`;
const conn = new window.WebSocket(url);
conn.onopen = () => conn.send(command);
conn.onmessage = onmessage;
conn.onclose = onclose;
}

View File

@@ -1,244 +1,111 @@
import { useAuthStore } from "@/stores/auth"; import { fetchAPI } from "./client";
import { useLayoutStore } from "@/stores/layout"; import type { IFile, FileListResponse } from "@/types";
import { baseURL } from "@/utils/constants";
import { upload as postTus, useTus } from "./tus";
import { createURL, fetchURL, removePrefix, StatusError } from "./utils";
import { isEncodableResponse, makeRawResource } from "@/utils/encodings";
export async function fetch(url: string, signal?: AbortSignal) { export async function fetchFiles(path: string): Promise<IFile[]> {
const encoding = isEncodableResponse(url); const response = await fetchAPI<FileListResponse>(`/resources${path}`);
url = removePrefix(url); return response.items || [];
const res = await fetchURL(`/api/resources${url}`, { }
signal,
headers: { export async function deleteFile(path: string): Promise<void> {
"X-Encoding": encoding ? "true" : "false", await fetchAPI(`/resources${path}`, {
}, method: "DELETE",
}); });
}
let data: Resource; export async function renameFile(path: string, name: string): Promise<void> {
try { await fetchAPI(`/resources${path}`, {
if (res.headers.get("Content-Type") == "application/octet-stream") { method: "PATCH",
data = await makeRawResource(res, url); body: JSON.stringify({ rename: name }),
} else {
data = (await res.json()) as Resource;
}
} catch (e) {
// Check if the error is an intentional cancellation
if (e instanceof Error && e.name === "AbortError") {
throw new StatusError("000 No connection", 0, true);
}
throw e;
}
data.url = `/files${url}`;
if (data.isDir) {
if (!data.url.endsWith("/")) data.url += "/";
// Perhaps change the any
data.items = data.items.map((item: any, index: any) => {
item.index = index;
item.url = `${data.url}${encodeURIComponent(item.name)}`;
if (item.isDir) {
item.url += "/";
}
return item;
}); });
}
return data;
} }
async function resourceAction(url: string, method: ApiMethod, content?: any) { export async function createDirectory(path: string, name: string): Promise<void> {
url = removePrefix(url); await fetchAPI(`/resources${path}`, {
method: "POST",
const opts: ApiOpts = { body: JSON.stringify({ mkdir: name }),
method, });
};
if (content) {
opts.body = content;
}
const res = await fetchURL(`/api/resources${url}`, opts);
return res;
} }
export async function remove(url: string) { export async function uploadFile(
return resourceAction(url, "DELETE"); path: string,
} file: File,
onProgress?: (progress: number) => void
): Promise<void> {
const formData = new FormData();
formData.append("file", file);
export async function put(url: string, content = "") { const xhr = new XMLHttpRequest();
return resourceAction(url, "PUT", content);
}
export function download(format: any, ...files: string[]) {
let url = `${baseURL}/api/raw`;
if (files.length === 1) {
url += removePrefix(files[0]) + "?";
} else {
let arg = "";
for (const file of files) {
arg += removePrefix(file) + ",";
}
arg = arg.substring(0, arg.length - 1);
arg = encodeURIComponent(arg);
url += `/?files=${arg}&`;
}
if (format) {
url += `algo=${format}&`;
}
window.open(url);
}
export async function post(
url: string,
content: ApiContent = "",
overwrite = false,
onupload: any = () => {}
) {
// Use the pre-existing API if:
const useResourcesApi =
// a folder is being created
url.endsWith("/") ||
// We're not using http(s)
(content instanceof Blob &&
!["http:", "https:"].includes(window.location.protocol)) ||
// Tus is disabled / not applicable
!(await useTus(content));
return useResourcesApi
? postResources(url, content, overwrite, onupload)
: postTus(url, content, overwrite, onupload);
}
async function postResources(
url: string,
content: ApiContent = "",
overwrite = false,
onupload: any
) {
url = removePrefix(url);
let bufferContent: ArrayBuffer;
if (
content instanceof Blob &&
!["http:", "https:"].includes(window.location.protocol)
) {
bufferContent = await new Response(content).arrayBuffer();
}
const authStore = useAuthStore();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = new XMLHttpRequest(); xhr.upload.addEventListener("progress", (e) => {
request.open( if (e.lengthComputable && onProgress) {
"POST", const percentComplete = (e.loaded / e.total) * 100;
`${baseURL}/api/resources${url}?override=${overwrite}`, onProgress(percentComplete);
true
);
request.setRequestHeader("X-Auth", authStore.jwt);
if (typeof onupload === "function") {
request.upload.onprogress = onupload;
} }
});
request.onload = () => { xhr.addEventListener("load", () => {
if (request.status === 200) { if (xhr.status >= 200 && xhr.status < 300) {
resolve(request.responseText); resolve();
} else if (request.status === 409) {
reject(new Error(request.status.toString()));
} else { } else {
reject(new Error(request.responseText)); reject(new Error(`Upload failed: ${xhr.statusText}`));
} }
}; });
request.onerror = () => { xhr.addEventListener("error", () => {
reject(new Error("001 Connection aborted")); reject(new Error("Upload failed"));
}; });
request.send(bufferContent || content); xhr.addEventListener("abort", () => {
reject(new Error("Upload canceled"));
});
const token = localStorage.getItem("auth_token");
xhr.open("POST", `/api/resources${path}`);
if (token) {
xhr.setRequestHeader("X-Auth", token);
}
xhr.send(formData);
}); });
} }
function moveCopy( export async function downloadFile(path: string): Promise<void> {
items: any[], const link = document.createElement("a");
copy = false, link.href = `/api/download${path}`;
overwrite = false, const token = localStorage.getItem("auth_token");
rename = false if (token) {
) { link.setAttribute("data-auth", token);
const layoutStore = useLayoutStore();
const promises = [];
for (const item of items) {
const from = item.from;
const to = encodeURIComponent(removePrefix(item.to ?? ""));
const finalOverwrite =
item.overwrite == undefined ? overwrite : item.overwrite;
const finalRename = item.rename == undefined ? rename : item.rename;
const url = `${from}?action=${
copy ? "copy" : "rename"
}&destination=${to}&override=${finalOverwrite}&rename=${finalRename}`;
promises.push(resourceAction(url, "PATCH"));
} }
layoutStore.closeHovers(); document.body.appendChild(link);
return Promise.all(promises); link.click();
document.body.removeChild(link);
} }
export function move(items: any[], overwrite = false, rename = false) { export async function copyFile(
return moveCopy(items, false, overwrite, rename); source: string,
destination: string
): Promise<void> {
await fetchAPI(`/resources${destination}`, {
method: "POST",
body: JSON.stringify({ copy: source }),
});
} }
export function copy(items: any[], overwrite = false, rename = false) { export async function moveFile(
return moveCopy(items, true, overwrite, rename); source: string,
destination: string
): Promise<void> {
await fetchAPI(`/resources${destination}`, {
method: "POST",
body: JSON.stringify({ move: source }),
});
} }
export async function checksum(url: string, algo: ChecksumAlg) { export async function getFilePreview(path: string): Promise<string> {
const data = await resourceAction(`${url}?checksum=${algo}`, "GET"); const token = localStorage.getItem("auth_token");
return (await data.json()).checksums[algo]; let url = `/api/preview${path}`;
} if (token) {
url += `?auth=${encodeURIComponent(token)}`;
export function getDownloadURL(file: ResourceItem, inline: any) {
const params = {
...(inline && { inline: "true" }),
};
return createURL("api/raw" + file.path, params);
}
export function getPreviewURL(file: ResourceItem, size: string) {
const params = {
inline: "true",
key: Date.parse(file.modified),
};
return createURL("api/preview/" + size + file.path, params);
}
export function getSubtitlesURL(file: ResourceItem) {
const params = {
inline: "true",
};
return file.subtitles?.map((d) => createURL("api/subtitle" + d, params));
}
export async function usage(url: string, signal: AbortSignal) {
url = removePrefix(url);
const res = await fetchURL(`/api/usage${url}`, { signal });
try {
return await res.json();
} catch (e) {
// Check if the error is an intentional cancellation
if (e instanceof Error && e.name == "AbortError") {
throw new StatusError("000 No connection", 0, true);
}
throw e;
} }
return url;
} }

View File

@@ -1,9 +1,14 @@
import * as files from "./files"; /**
import * as share from "./share"; * API namespace exports
import * as users from "./users"; * Re-export all API functions grouped by domain
import * as settings from "./settings"; */
import * as pub from "./pub";
import search from "./search";
import commands from "./commands";
export { files, share, users, settings, pub, commands, search }; export * as auth from "./auth";
export * as files from "./files";
export * as settings from "./settings";
export * as users from "./users";
export * from "./preview";
export * from "./search";
export * from "./share";
export * from "./client";
export * from "./queryKeys";

View File

@@ -0,0 +1,64 @@
import { API_BASE_URL } from "./client";
/**
* Get preview URL for a file
* @param path - File path
* @param size - Preview size (128, 256, 512)
*/
export function getPreviewUrl(path: string, size: number = 256): string {
const encodedPath = encodeURIComponent(path.slice(1)); // Remove leading slash
return `${API_BASE_URL}/preview/${size}/${encodedPath}`;
}
/**
* Check if a file type is previewable
*/
export function isPreviewable(filename: string): boolean {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const previewableTypes = [
// Images
"jpg",
"jpeg",
"png",
"gif",
"webp",
"svg",
"bmp",
// Video
"mp4",
"webm",
"ogv",
// Audio
"mp3",
"wav",
"ogg",
// Documents
"pdf",
"txt",
"md",
"json",
"xml",
"html",
"css",
"js",
];
return previewableTypes.includes(ext);
}
/**
* Get file type category
*/
export function getFileType(filename: string): "image" | "video" | "audio" | "document" | "unknown" {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"];
const videoExts = ["mp4", "webm", "ogv"];
const audioExts = ["mp3", "wav", "ogg"];
const docExts = ["pdf", "txt", "md", "json", "xml", "html", "css", "js"];
if (imageExts.includes(ext)) return "image";
if (videoExts.includes(ext)) return "video";
if (audioExts.includes(ext)) return "audio";
if (docExts.includes(ext)) return "document";
return "unknown";
}

View File

@@ -1,75 +0,0 @@
import { fetchURL, removePrefix, createURL } from "./utils";
import { baseURL } from "@/utils/constants";
export async function fetch(url: string, password: string = "") {
url = removePrefix(url);
const res = await fetchURL(
`/api/public/share${url}`,
{
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password) },
},
false
);
const data = (await res.json()) as Resource;
data.url = `/share${url}`;
if (data.isDir) {
if (!data.url.endsWith("/")) data.url += "/";
data.items = data.items.map((item: any, index: any) => {
item.index = index;
item.url = `${data.url}${encodeURIComponent(item.name)}`;
if (item.isDir) {
item.url += "/";
}
return item;
});
}
return data;
}
export function download(
format: DownloadFormat,
hash: string,
token: string,
...files: string[]
) {
let url = `${baseURL}/api/public/dl/${hash}`;
if (files.length === 1) {
url += files[0] + "?";
} else {
let arg = "";
for (const file of files) {
arg += file + ",";
}
arg = arg.substring(0, arg.length - 1);
arg = encodeURIComponent(arg);
url += `/?files=${arg}&`;
}
if (format) {
url += `algo=${format}&`;
}
if (token) {
url += `token=${token}&`;
}
window.open(url);
}
export function getDownloadURL(res: Resource, inline = false) {
const params = {
...(inline && { inline: "true" }),
...(res.token && { token: res.token }),
};
return createURL("api/public/dl/" + res.hash + res.path, params);
}

View File

@@ -0,0 +1,38 @@
/**
* TanStack Query key factory functions
* Used to create consistent, nested query keys for caching
*/
export const filesKeys = {
all: ["files"] as const,
lists: () => [...filesKeys.all, "list"] as const,
list: (path: string) => [...filesKeys.lists(), path] as const,
details: () => [...filesKeys.all, "detail"] as const,
detail: (path: string) => [...filesKeys.details(), path] as const,
};
export const usersKeys = {
all: ["users"] as const,
lists: () => [...usersKeys.all, "list"] as const,
list: () => [...usersKeys.lists()] as const,
details: () => [...usersKeys.all, "detail"] as const,
detail: (id: number) => [...usersKeys.details(), id] as const,
};
export const settingsKeys = {
all: ["settings"] as const,
global: () => [...settingsKeys.all, "global"] as const,
};
export const authKeys = {
all: ["auth"] as const,
me: () => [...authKeys.all, "me"] as const,
};
export const shareKeys = {
all: ["share"] as const,
lists: () => [...shareKeys.all, "list"] as const,
list: () => [...shareKeys.lists()] as const,
details: () => [...shareKeys.all, "detail"] as const,
detail: (hash: string) => [...shareKeys.details(), hash] as const,
};

View File

@@ -1,73 +1,33 @@
import { fetchURL, removePrefix, StatusError } from "./utils"; import { fetchAPI } from "./client";
import url from "../utils/url"; import type { IFile } from "@/types";
export default async function search( export interface SearchResult extends IFile {}
base: string,
query: string,
signal: AbortSignal,
callback: (item: ResourceItem) => void
) {
base = removePrefix(base);
query = encodeURIComponent(query);
if (!base.endsWith("/")) { export interface SearchResponse {
base += "/"; items: SearchResult[];
} }
const res = await fetchURL(`/api/search${base}?query=${query}`, { signal }); /**
if (!res.body) { * Search files by query
throw new StatusError("000 No connection", 0); * @param query - Search query string
} * @param path - Optional path to search within
try { */
// Try streaming approach first (modern browsers) export async function searchFiles(
if (res.body && typeof res.body.pipeThrough === "function") { query: string,
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); path: string = "/"
let buffer = ""; ): Promise<SearchResult[]> {
while (true) { if (!query.trim()) {
const { done, value } = await reader.read(); return [];
if (value) { }
buffer += value;
} const params = new URLSearchParams();
const lines = buffer.split(/\n/); params.append("q", query);
let lastLine = lines.pop(); if (path && path !== "/") {
// Save incomplete last line params.append("scope", path);
if (!lastLine) { }
lastLine = "";
} const response = await fetchAPI<SearchResponse>(
buffer = lastLine; `/search?${params.toString()}`
);
for (const line of lines) { return response.items || [];
if (line) {
const item = JSON.parse(line) as ResourceItem;
item.url = `/files${base}` + url.encodePath(item.path);
if (item.isDir) {
item.url += "/";
}
callback(item);
}
}
if (done) break;
}
} else {
// Fallback for browsers without streaming support (e.g., Safari)
const text = await res.text();
const lines = text.split(/\n/);
for (const line of lines) {
if (line) {
const item = JSON.parse(line) as ResourceItem;
item.url = `/files${base}` + url.encodePath(item.path);
if (item.isDir) {
item.url += "/";
}
callback(item);
}
}
}
} catch (e) {
// Check if the error is an intentional cancellation
if (e instanceof Error && e.name === "AbortError") {
throw new StatusError("000 No connection", 0, true);
}
throw e;
}
} }

View File

@@ -1,12 +1,27 @@
import { fetchURL, fetchJSON } from "./utils"; import { fetchAPI } from "./client";
import type { ISettings } from "@/types";
export function get() { /**
return fetchJSON<ISettings>(`/api/settings`, {}); * Save user preferences (theme, view mode, language)
} */
export async function savePreferences(preferences: {
export async function update(settings: ISettings) { locale?: string;
await fetchURL(`/api/settings`, { viewMode?: string;
singleClick?: boolean;
sorting?: {
by: string;
asc: boolean;
};
}): Promise<ISettings> {
return fetchAPI<ISettings>("/settings", {
method: "PUT", method: "PUT",
body: JSON.stringify(settings), body: JSON.stringify(preferences),
}); });
} }
/**
* Get global settings
*/
export async function getSettings(): Promise<ISettings> {
return fetchAPI<ISettings>("/settings");
}

View File

@@ -1,45 +1,54 @@
import { fetchURL, fetchJSON, removePrefix, createURL } from "./utils"; import { fetchAPI } from "./client";
import type { IFile, IShare } from "@/types";
export async function list() { /**
return fetchJSON<Share[]>("/api/shares"); * Get public share info
*/
export async function getPublicShare(token: string): Promise<IShare> {
return fetchAPI<IShare>(`/public/share/${token}`);
} }
export async function get(url: string) { /**
url = removePrefix(url); * Download from a public share
return fetchJSON<Share>(`/api/share${url}`); */
export function getPublicDownloadUrl(token: string, path: string): string {
const encodedPath = encodeURIComponent(path);
return `/api/public/dl/${token}/${encodedPath}`;
} }
export async function remove(hash: string) { /**
await fetchURL(`/api/share/${hash}`, { * Create a share link
*/
export async function createShare(
path: string,
options?: {
password?: string;
expire?: number;
unit?: string;
clicks?: number;
}
): Promise<IShare> {
return fetchAPI<IShare>("/share", {
method: "POST",
body: JSON.stringify({
path,
...options,
}),
});
}
/**
* Delete a share link
*/
export async function deleteShare(token: string): Promise<void> {
await fetchAPI(`/share/${token}`, {
method: "DELETE", method: "DELETE",
}); });
} }
export async function create( /**
url: string, * List all shares (admin only)
password = "", */
expires = "", export async function listShares(): Promise<IShare[]> {
unit = "hours" return fetchAPI<IShare[]>("/shares");
) {
url = removePrefix(url);
url = `/api/share${url}`;
if (expires !== "") {
url += `?expires=${expires}&unit=${unit}`;
}
let body = "{}";
if (password != "" || expires !== "" || unit !== "hours") {
body = JSON.stringify({
password: password,
expires: expires.toString(), // backend expects string not number
unit: unit,
});
}
return fetchJSON(url, {
method: "POST",
body: body,
});
}
export function getShareURL(share: Share) {
return createURL("share/" + share.hash, {});
} }

View File

@@ -1,122 +0,0 @@
import * as tus from "tus-js-client";
import { baseURL, tusEndpoint, tusSettings, origin } from "@/utils/constants";
import { useAuthStore } from "@/stores/auth";
import { removePrefix } from "@/api/utils";
const RETRY_BASE_DELAY = 1000;
const RETRY_MAX_DELAY = 20000;
const CURRENT_UPLOAD_LIST: { [key: string]: tus.Upload } = {};
export async function upload(
filePath: string,
content: ApiContent = "",
overwrite = false,
onupload: any
) {
if (!tusSettings) {
// Shouldn't happen as we check for tus support before calling this function
throw new Error("Tus.io settings are not defined");
}
filePath = removePrefix(filePath);
const resourcePath = `${tusEndpoint}${filePath}?override=${overwrite}`;
const authStore = useAuthStore();
// Exit early because of typescript, tus content can't be a string
if (content === "") {
return false;
}
return new Promise<void | string>((resolve, reject) => {
const upload = new tus.Upload(content, {
endpoint: `${origin}${baseURL}${resourcePath}`,
chunkSize: tusSettings.chunkSize,
retryDelays: computeRetryDelays(tusSettings),
parallelUploads: 1,
storeFingerprintForResuming: false,
headers: {
"X-Auth": authStore.jwt,
},
onShouldRetry: function (err) {
const status = err.originalResponse
? err.originalResponse.getStatus()
: 0;
// Do not retry for file conflict.
if (status === 409) {
return false;
}
return true;
},
onError: function (error: Error | tus.DetailedError) {
delete CURRENT_UPLOAD_LIST[filePath];
if (error.message === "Upload aborted") {
return reject(error);
}
const message =
error instanceof tus.DetailedError
? error.originalResponse === null
? "000 No connection"
: error.originalResponse.getBody()
: "Upload failed";
console.error(error);
reject(new Error(message));
},
onProgress: function (bytesUploaded) {
if (typeof onupload === "function") {
onupload({ loaded: bytesUploaded });
}
},
onSuccess: function () {
delete CURRENT_UPLOAD_LIST[filePath];
resolve();
},
});
CURRENT_UPLOAD_LIST[filePath] = upload;
upload.start();
});
}
function computeRetryDelays(tusSettings: TusSettings): number[] | undefined {
if (!tusSettings.retryCount || tusSettings.retryCount < 1) {
// Disable retries altogether
return undefined;
}
// The tus client expects our retries as an array with computed backoffs
// E.g.: [0, 3000, 5000, 10000, 20000]
const retryDelays = [];
let delay = 0;
for (let i = 0; i < tusSettings.retryCount; i++) {
retryDelays.push(Math.min(delay, RETRY_MAX_DELAY));
delay =
delay === 0 ? RETRY_BASE_DELAY : Math.min(delay * 2, RETRY_MAX_DELAY);
}
return retryDelays;
}
export async function useTus(content: ApiContent) {
return isTusSupported() && content instanceof Blob;
}
function isTusSupported() {
return tus.isSupported === true;
}
export function abortAllUploads() {
for (const filePath in CURRENT_UPLOAD_LIST) {
if (CURRENT_UPLOAD_LIST[filePath]) {
CURRENT_UPLOAD_LIST[filePath].abort(true);
CURRENT_UPLOAD_LIST[filePath].options!.onError!(
new Error("Upload aborted")
);
}
delete CURRENT_UPLOAD_LIST[filePath];
}
}

View File

@@ -1,55 +1,65 @@
import { fetchURL, fetchJSON, StatusError } from "./utils"; import { fetchAPI } from "./client";
import type { IUser } from "@/types";
export async function getAll() { export interface CreateUserRequest {
return fetchJSON<IUser[]>(`/api/users`, {}); username: string;
password: string;
admin?: boolean;
} }
export async function get(id: number) { export interface UpdateUserRequest {
return fetchJSON<IUser>(`/api/users/${id}`, {}); username?: string;
password?: string;
admin?: boolean;
locale?: string;
viewMode?: string;
singleClick?: boolean;
} }
export async function create(user: IUser, currentPassword: string) { /**
const res = await fetchURL(`/api/users`, { * Get all users (admin only)
*/
export async function listUsers(): Promise<IUser[]> {
return fetchAPI<IUser[]>("/users");
}
/**
* Get a specific user by ID
*/
export async function getUser(id: number): Promise<IUser> {
return fetchAPI<IUser>(`/users/${id}`);
}
/**
* Create a new user (admin only)
*/
export async function createUser(
request: CreateUserRequest
): Promise<IUser> {
return fetchAPI<IUser>("/users", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify(request),
what: "user",
which: [],
current_password: currentPassword,
data: user,
}),
});
if (res.status === 201) {
return res.headers.get("Location");
}
throw new StatusError(await res.text(), res.status);
}
export async function update(
user: Partial<IUser>,
which = ["all"],
currentPassword: string | null = null
) {
await fetchURL(`/api/users/${user.id}`, {
method: "PUT",
body: JSON.stringify({
what: "user",
which: which,
...(currentPassword != null ? { current_password: currentPassword } : {}),
data: user,
}),
}); });
} }
export async function remove( /**
* Update a user (admin only)
*/
export async function updateUser(
id: number, id: number,
currentPassword: string | null = null request: UpdateUserRequest
) { ): Promise<IUser> {
await fetchURL(`/api/users/${id}`, { return fetchAPI<IUser>(`/users/${id}`, {
method: "DELETE", method: "PUT",
body: JSON.stringify({ body: JSON.stringify(request),
...(currentPassword != null ? { current_password: currentPassword } : {}), });
}), }
/**
* Delete a user (admin only)
*/
export async function deleteUser(id: number): Promise<void> {
await fetchAPI(`/users/${id}`, {
method: "DELETE",
}); });
} }

View File

@@ -1,111 +0,0 @@
import { useAuthStore } from "@/stores/auth";
import { renew, logout } from "@/utils/auth";
import { baseURL } from "@/utils/constants";
import { encodePath } from "@/utils/url";
export class StatusError extends Error {
constructor(
message: any,
public status?: number,
public is_canceled?: boolean
) {
super(message);
this.name = "StatusError";
}
}
export async function fetchURL(
url: string,
opts: ApiOpts,
auth = true
): Promise<Response> {
const authStore = useAuthStore();
opts = opts || {};
opts.headers = opts.headers || {};
const { headers, ...rest } = opts;
let res;
try {
res = await fetch(`${baseURL}${url}`, {
headers: {
"X-Auth": authStore.jwt,
...headers,
},
...rest,
});
} catch (e) {
// Check if the error is an intentional cancellation
if (e instanceof Error && e.name === "AbortError") {
throw new StatusError("000 No connection", 0, true);
}
throw new StatusError("000 No connection", 0);
}
if (auth && res.headers.get("X-Renew-Token") === "true") {
await renew(authStore.jwt);
}
if (res.status < 200 || res.status > 299) {
const body = await res.text();
const error = new StatusError(
body || `${res.status} ${res.statusText}`,
res.status
);
if (auth && res.status == 401) {
logout();
}
throw error;
}
return res;
}
export async function fetchJSON<T>(url: string, opts?: any): Promise<T> {
const res = await fetchURL(url, opts);
if (res.status === 200) {
return res.json() as Promise<T>;
}
throw new StatusError(`${res.status} ${res.statusText}`, res.status);
}
export function removePrefix(url: string): string {
url = url.split("/").splice(2).join("/");
if (url === "") url = "/";
if (url[0] !== "/") url = "/" + url;
return url;
}
export function createURL(endpoint: string, searchParams = {}): string {
let prefix = baseURL;
if (!prefix.endsWith("/")) {
prefix = prefix + "/";
}
const url = new URL(prefix + encodePath(endpoint), origin);
url.search = new URLSearchParams(searchParams).toString();
return url.toString();
}
export function setSafeTimeout(callback: () => void, delay: number): number {
const MAX_DELAY = 86_400_000;
let remaining = delay;
function scheduleNext(): number {
if (remaining <= MAX_DELAY) {
return window.setTimeout(callback, remaining);
} else {
return window.setTimeout(() => {
remaining -= MAX_DELAY;
scheduleNext();
}, MAX_DELAY);
}
}
return scheduleNext();
}

View File

@@ -1,83 +0,0 @@
<template>
<div class="breadcrumbs">
<component
:is="element"
:to="base || ''"
:aria-label="t('files.home')"
:title="t('files.home')"
>
<i class="material-icons">home</i>
</component>
<span v-for="(link, index) in items" :key="index">
<span class="chevron"
><i class="material-icons">keyboard_arrow_right</i></span
>
<component :is="element" :to="link.url">{{ link.name }}</component>
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
const { t } = useI18n();
const route = useRoute();
const props = defineProps<{
base: string;
noLink?: boolean;
}>();
const items = computed(() => {
const relativePath = route.path.replace(props.base, "");
const parts = relativePath.split("/");
if (parts[0] === "") {
parts.shift();
}
if (parts[parts.length - 1] === "") {
parts.pop();
}
const breadcrumbs: BreadCrumb[] = [];
for (let i = 0; i < parts.length; i++) {
if (i === 0) {
breadcrumbs.push({
name: decodeURIComponent(parts[i]),
url: props.base + "/" + parts[i] + "/",
});
} else {
breadcrumbs.push({
name: decodeURIComponent(parts[i]),
url: breadcrumbs[i - 1].url + parts[i] + "/",
});
}
}
if (breadcrumbs.length > 3) {
while (breadcrumbs.length !== 4) {
breadcrumbs.shift();
}
breadcrumbs[0].name = "...";
}
return breadcrumbs;
});
const element = computed(() => {
if (props.noLink) {
return "span";
}
return "router-link";
});
</script>
<style></style>

View File

@@ -1,47 +0,0 @@
<template>
<div
class="context-menu"
ref="contextMenu"
v-show="show"
:style="{
top: `${props.pos.y}px`,
left: `${left}px`,
}"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed, onUnmounted } from "vue";
const emit = defineEmits(["hide"]);
const props = defineProps<{ show: boolean; pos: { x: number; y: number } }>();
const contextMenu = ref<HTMLElement | null>(null);
const left = computed(() => {
return Math.min(
props.pos.x,
window.innerWidth - (contextMenu.value?.clientWidth ?? 0)
);
});
const hideContextMenu = () => {
emit("hide");
};
watch(
() => props.show,
(val) => {
if (val) {
document.addEventListener("click", hideContextMenu);
} else {
document.removeEventListener("click", hideContextMenu);
}
}
);
onUnmounted(() => {
document.removeEventListener("click", hideContextMenu);
});
</script>

View File

@@ -1,45 +0,0 @@
<template>
<div class="t-container">
<span>{{ message }}</span>
<button v-if="isReport" class="action" @click.stop="clicked">
{{ reportText }}
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
message: string;
reportText?: string;
isReport?: boolean;
}>();
const clicked = () => {
window.open("https://github.com/filebrowser/filebrowser/issues/new/choose");
};
</script>
<style scoped>
.t-container {
width: 100%;
padding: 5px 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.action {
text-align: center;
height: 40px;
padding: 0 10px;
margin-left: 20px;
border-radius: 5px;
color: white;
cursor: pointer;
border: thin solid currentColor;
}
html[dir="rtl"] .action {
margin-left: initial;
margin-right: 20px;
}
</style>

View File

@@ -1,142 +0,0 @@
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
import { vClickOutside } from "@/utils/index";
const props = withDefaults(
defineProps<{
position?: "top-left" | "bottom-left" | "bottom-right" | "top-right";
closeOnClick?: boolean;
}>(),
{
position: "bottom-right",
closeOnClick: true,
}
);
const isOpen = defineModel<boolean>();
const triggerRef = ref<HTMLElement | null>(null);
const listRef = ref<HTMLElement | null>(null);
const dropdownStyle = ref<Record<string, string>>({});
const updatePosition = () => {
if (!isOpen.value || !triggerRef.value) return;
const rect = triggerRef.value.getBoundingClientRect();
dropdownStyle.value = {
position: "fixed",
top: props.position.includes("bottom")
? `${rect.bottom + 2}px`
: `${rect.top}px`,
left: props.position.includes("left") ? `${rect.left}px` : "auto",
right: props.position.includes("right")
? `${window.innerWidth - rect.right}px`
: "auto",
zIndex: "11000",
};
};
watch(isOpen, (open) => {
if (open) {
updatePosition();
}
});
const onWindowChange = () => {
updatePosition();
};
const closeDropdown = (e: Event) => {
if (
e.target instanceof HTMLElement &&
listRef.value?.contains(e.target) &&
!props.closeOnClick
) {
return;
}
isOpen.value = false;
};
onMounted(() => {
window.addEventListener("resize", onWindowChange);
window.addEventListener("scroll", onWindowChange, true);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", onWindowChange);
window.removeEventListener("scroll", onWindowChange, true);
});
</script>
<script lang="ts">
export default {
directives: {
clickOutside: vClickOutside,
},
};
</script>
<template>
<div
class="dropdown-modal-container"
v-click-outside="closeDropdown"
ref="triggerRef"
>
<button @click="isOpen = !isOpen" class="dropdown-modal-trigger">
<slot></slot>
<i class="material-icons">chevron_right</i>
</button>
<teleport to="body">
<div
ref="listRef"
class="dropdown-modal-list"
:class="{ 'dropdown-modal-open': isOpen }"
:style="dropdownStyle"
>
<div>
<slot name="list"></slot>
</div>
</div>
</teleport>
</div>
</template>
<style scoped>
.dropdown-modal-trigger {
background: var(--surfacePrimary);
color: var(--textSecondary);
border: 1px solid var(--borderPrimary);
border-radius: 0.1em;
padding: 0.5em 1em;
transition: 0.2s ease all;
margin: 0;
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.dropdown-modal-trigger > i {
transform: rotate(90deg);
}
.dropdown-modal-list {
padding: 0.25rem;
background-color: var(--surfacePrimary);
color: var(--textSecondary);
display: none;
border: 1px solid var(--borderPrimary);
border-radius: 0.1em;
}
.dropdown-modal-list > div {
max-height: 450px;
padding: 0.25rem;
}
.dropdown-modal-open {
display: block;
}
</style>

View File

@@ -0,0 +1,79 @@
import React, { Component, ErrorInfo, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: (error: Error, retry: () => void) => ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* Error Boundary component to catch React errors
* Displays a user-friendly error message instead of crashing
*/
export class ErrorBoundary extends Component<Props, State> {
public constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
private handleRetry = () => {
this.setState({ hasError: false, error: null });
};
public render() {
if (this.state.hasError) {
if (this.props.fallback && this.state.error) {
return this.props.fallback(this.state.error, this.handleRetry);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="w-full max-w-md rounded-lg border border-red-200 bg-white p-6 shadow-lg dark:border-red-900 dark:bg-slate-800">
<h2 className="mb-4 text-lg font-semibold text-red-900 dark:text-red-200">
Oops! Something went wrong
</h2>
<p className="mb-4 text-sm text-red-800 dark:text-red-300">
{this.state.error?.message || "An unexpected error occurred"}
</p>
<details className="mb-4">
<summary className="cursor-pointer text-xs font-medium text-red-700 dark:text-red-400">
Error details
</summary>
<pre className="mt-2 overflow-auto rounded bg-red-50 p-2 text-xs dark:bg-red-950">
{this.state.error?.stack}
</pre>
</details>
<div className="flex gap-2">
<button
onClick={this.handleRetry}
className="flex-1 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 dark:hover:bg-red-800"
>
Try Again
</button>
<button
onClick={() => window.location.href = "/"}
className="flex-1 rounded-md border border-red-200 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-950"
>
Go Home
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,136 @@
import { getPreviewUrl, getFileType, isPreviewable } from "@/api/preview";
import { useState } from "react";
interface FilePreviewProps {
path: string;
filename: string;
isDir?: boolean;
}
export function FilePreview({ path, filename, isDir = false }: FilePreviewProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const [scale, setScale] = useState(100);
const fileType = getFileType(filename);
const canPreview = !isDir && isPreviewable(filename);
if (!canPreview) {
return (
<div className="flex items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-12 dark:border-slate-600 dark:bg-slate-900">
<div className="text-center">
<p className="text-lg font-medium text-gray-900 dark:text-gray-50">
{isDir ? "📁 Directory" : "📄 File"}
</p>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Preview not available for this file type
</p>
</div>
</div>
);
}
const previewUrl = getPreviewUrl(path, 512);
return (
<div className="space-y-4">
{/* Preview Controls */}
{fileType === "image" && (
<div className="flex items-center justify-between rounded-lg bg-gray-100 p-3 dark:bg-slate-800">
<div className="flex items-center gap-2">
<button
onClick={() => setScale(Math.max(50, scale - 10))}
className="rounded px-2 py-1 text-sm hover:bg-gray-200 dark:hover:bg-slate-700"
>
</button>
<span className="w-12 text-center text-sm font-medium">{scale}%</span>
<button
onClick={() => setScale(Math.min(200, scale + 10))}
className="rounded px-2 py-1 text-sm hover:bg-gray-200 dark:hover:bg-slate-700"
>
+
</button>
</div>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="rounded px-3 py-1 text-sm hover:bg-gray-200 dark:hover:bg-slate-700"
>
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
</button>
</div>
)}
{/* Preview Container */}
<div
className={`flex items-center justify-center rounded-lg border border-gray-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-800 ${
isFullscreen ? "fixed inset-0 z-50 rounded-none border-0 p-0" : "min-h-96"
}`}
>
{fileType === "image" && (
<div
className="flex items-center justify-center overflow-auto"
style={{
maxHeight: isFullscreen ? "100vh" : "500px",
maxWidth: isFullscreen ? "100vw" : "100%",
}}
>
<img
src={previewUrl}
alt={filename}
style={{
maxHeight: isFullscreen ? "100vh" : "500px",
maxWidth: isFullscreen ? "100vw" : "100%",
transform: `scale(${scale / 100})`,
transition: "transform 0.2s",
}}
className="rounded"
/>
</div>
)}
{fileType === "video" && (
<video
src={previewUrl}
controls
style={{
maxHeight: isFullscreen ? "100vh" : "500px",
maxWidth: isFullscreen ? "100vw" : "100%",
}}
className="rounded"
/>
)}
{fileType === "audio" && (
<div className="w-full max-w-md">
<audio
src={previewUrl}
controls
className="w-full"
/>
</div>
)}
{fileType === "document" && (
<iframe
src={previewUrl}
style={{
width: isFullscreen ? "100vw" : "100%",
height: isFullscreen ? "100vh" : "500px",
}}
className="rounded border-0"
title={filename}
/>
)}
</div>
{/* File Info */}
<div className="rounded-lg bg-gray-50 p-4 dark:bg-slate-900">
<h3 className="font-medium text-gray-900 dark:text-gray-50">
{filename}
</h3>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{path}
</p>
</div>
</div>
);
}

View File

@@ -1,225 +0,0 @@
<!-- This component taken directly from vue-simple-progress
since it didnt support Vue 3 but the component itself does
https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/components/Progress.vue -->
<template>
<div>
<div
class="vue-simple-progress-text"
:style="text_style"
v-if="text.length > 0 && textPosition == 'top'"
>
{{ text }}
</div>
<div class="vue-simple-progress" :style="progress_style">
<div
class="vue-simple-progress-text"
:style="text_style"
v-if="text.length > 0 && textPosition == 'middle'"
>
{{ text }}
</div>
<div
style="position: relative; left: -9999px"
:style="text_style"
v-if="text.length > 0 && textPosition == 'inside'"
>
{{ text }}
</div>
<div class="vue-simple-progress-bar" :style="bar_style">
<div
:style="text_style"
v-if="text.length > 0 && textPosition == 'inside'"
>
{{ text }}
</div>
</div>
</div>
<div
class="vue-simple-progress-text"
:style="text_style"
v-if="text.length > 0 && textPosition == 'bottom'"
>
{{ text }}
</div>
</div>
</template>
<script>
// We're leaving this untouched as you can read in the beginning
const isNumber = function (n) {
return !isNaN(parseFloat(n)) && isFinite(n);
};
export default {
name: "progress-bar",
props: {
val: {
default: 0,
},
max: {
default: 100,
},
size: {
// either a number (pixel width/height) or 'tiny', 'small',
// 'medium', 'large', 'huge', 'massive' for common sizes
default: 3,
},
"bg-color": {
type: String,
default: "#eee",
},
"bar-color": {
type: String,
default: "#2196f3", // match .blue color to Material Design's 'Blue 500' color
},
"bar-transition": {
type: String,
default: "all 0.5s ease",
},
"bar-border-radius": {
type: Number,
default: 0,
},
spacing: {
type: Number,
default: 4,
},
text: {
type: String,
default: "",
},
"text-align": {
type: String,
default: "center", // 'left', 'right'
},
"text-position": {
type: String,
default: "bottom", // 'bottom', 'top', 'middle', 'inside'
},
"font-size": {
type: Number,
default: 13,
},
"text-fg-color": {
type: String,
default: "#222",
},
},
computed: {
pct() {
let pct = (this.val / this.max) * 100;
pct = pct.toFixed(2);
return Math.min(pct, this.max);
},
size_px() {
switch (this.size) {
case "tiny":
return 2;
case "small":
return 4;
case "medium":
return 8;
case "large":
return 12;
case "big":
return 16;
case "huge":
return 32;
case "massive":
return 64;
}
return isNumber(this.size) ? this.size : 32;
},
text_padding() {
switch (this.size) {
case "tiny":
case "small":
case "medium":
case "large":
case "big":
case "huge":
case "massive":
return Math.min(Math.max(Math.ceil(this.size_px / 8), 3), 12);
}
return isNumber(this.spacing) ? this.spacing : 4;
},
text_font_size() {
switch (this.size) {
case "tiny":
case "small":
case "medium":
case "large":
case "big":
case "huge":
case "massive":
return Math.min(Math.max(Math.ceil(this.size_px * 1.4), 11), 32);
}
return isNumber(this.fontSize) ? this.fontSize : 13;
},
progress_style() {
const style = {
background: this.bgColor,
};
if (this.textPosition == "middle" || this.textPosition == "inside") {
style["position"] = "relative";
style["min-height"] = this.size_px + "px";
style["z-index"] = "-2";
}
if (this.barBorderRadius > 0) {
style["border-radius"] = this.barBorderRadius + "px";
}
return style;
},
bar_style() {
const style = {
background: this.barColor,
width: this.pct + "%",
height: this.size_px + "px",
transition: this.barTransition,
};
if (this.barBorderRadius > 0) {
style["border-radius"] = this.barBorderRadius + "px";
}
if (this.textPosition == "middle" || this.textPosition == "inside") {
style["position"] = "absolute";
style["top"] = "0";
style["height"] = "100%";
((style["min-height"] = this.size_px + "px"),
(style["z-index"] = "-1"));
}
return style;
},
text_style() {
const style = {
color: this.textFgColor,
"font-size": this.text_font_size + "px",
"text-align": this.textAlign,
};
if (
this.textPosition == "top" ||
this.textPosition == "middle" ||
this.textPosition == "inside"
)
style["padding-bottom"] = this.text_padding + "px";
if (
this.textPosition == "bottom" ||
this.textPosition == "middle" ||
this.textPosition == "inside"
)
style["padding-top"] = this.text_padding + "px";
return style;
},
},
};
</script>

View File

@@ -0,0 +1,54 @@
import { useAuth } from "@/context/AuthContext";
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
export interface ProtectedRouteProps {
children: React.ReactNode;
requireAdmin?: boolean;
}
export function ProtectedRoute({
children,
requireAdmin = false,
}: ProtectedRouteProps) {
const { isAuthenticated, isLoading, user } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
navigate({ to: "/login" });
}
}, [isAuthenticated, isLoading, navigate]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-blue-600" />
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
if (requireAdmin && !user?.admin) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">
Access Denied
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
You do not have permission to access this page
</p>
</div>
</div>
);
}
return <>{children}</>;
}

View File

@@ -1,251 +0,0 @@
<template>
<div id="search" @click="open" v-bind:class="{ active, ongoing }">
<div id="input">
<button
v-if="active"
class="action"
@click="close"
:aria-label="closeButtonTitle"
:title="closeButtonTitle"
>
<i v-if="ongoing" class="material-icons">stop_circle</i>
<i v-else class="material-icons">arrow_back</i>
</button>
<i v-else class="material-icons">search</i>
<input
type="text"
@keyup.exact="keyup"
@keyup.enter="submit"
ref="input"
:autofocus="active"
v-model.trim="prompt"
:aria-label="$t('search.search')"
:placeholder="$t('search.search')"
/>
<i
v-show="ongoing"
class="material-icons spin"
style="display: inline-block"
>autorenew
</i>
<span style="margin-top: 5px" v-show="results.length > 0">
{{ results.length }}
</span>
</div>
<div id="result" ref="result">
<div>
<template v-if="isEmpty">
<p>{{ text }}</p>
<template v-if="prompt.length === 0">
<div class="boxes">
<h3>{{ $t("search.types") }}</h3>
<div>
<div
tabindex="0"
v-for="(v, k) in boxes"
:key="k"
role="button"
@click="init('type:' + k)"
:aria-label="$t('search.' + v.label)"
>
<i class="material-icons">{{ v.icon }}</i>
<p>{{ $t("search." + v.label) }}</p>
</div>
</div>
</div>
</template>
</template>
<ul v-show="results.length > 0">
<li v-for="(s, k) in filteredResults" :key="k">
<router-link v-on:click="close" :to="s.url">
<i v-if="s.dir" class="material-icons">folder</i>
<i v-else class="material-icons">insert_drive_file</i>
<span>./{{ s.path }}</span>
</router-link>
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import url from "@/utils/url";
import { search } from "@/api";
import { computed, inject, onMounted, ref, watch, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import { storeToRefs } from "pinia";
import { StatusError } from "@/api/utils";
const boxes = {
image: { label: "images", icon: "insert_photo" },
audio: { label: "music", icon: "volume_up" },
video: { label: "video", icon: "movie" },
pdf: { label: "pdf", icon: "picture_as_pdf" },
};
const layoutStore = useLayoutStore();
const fileStore = useFileStore();
let searchAbortController = new AbortController();
const { currentPromptName } = storeToRefs(layoutStore);
const prompt = ref<string>("");
const active = ref<boolean>(false);
const ongoing = ref<boolean>(false);
const results = ref<any[]>([]);
const reload = ref<boolean>(false);
const resultsCount = ref<number>(50);
const $showError = inject<IToastError>("$showError")!;
const input = ref<HTMLInputElement | null>(null);
const result = ref<HTMLElement | null>(null);
const { t } = useI18n();
const route = useRoute();
watch(currentPromptName, (newVal, oldVal) => {
active.value = newVal === "search";
if (oldVal === "search" && !active.value) {
if (reload.value) {
fileStore.reload = true;
}
document.body.style.overflow = "auto";
reset();
prompt.value = "";
active.value = false;
input.value?.blur();
} else if (active.value) {
reload.value = false;
input.value?.focus();
document.body.style.overflow = "hidden";
}
});
watch(prompt, () => {
reset();
});
// ...mapState(useFileStore, ["isListing"]),
// ...mapState(useLayoutStore, ["show"]),
// ...mapWritableState(useFileStore, { sReload: "reload" }),
const isEmpty = computed(() => {
return results.value.length === 0;
});
const text = computed(() => {
if (ongoing.value) {
return "";
}
return prompt.value === ""
? t("search.typeToSearch")
: t("search.pressToSearch");
});
const filteredResults = computed(() => {
return results.value.slice(0, resultsCount.value);
});
const closeButtonTitle = computed(() => {
return ongoing.value ? t("buttons.stopSearch") : t("buttons.close");
});
onMounted(() => {
if (result.value === null) {
return;
}
result.value.addEventListener("scroll", (event: Event) => {
if (
(event.target as HTMLElement).offsetHeight +
(event.target as HTMLElement).scrollTop >=
(event.target as HTMLElement).scrollHeight - 100
) {
resultsCount.value += 50;
}
});
});
onUnmounted(() => {
abortLastSearch();
});
const open = () => {
!active.value && layoutStore.showHover("search");
};
const close = (event: Event) => {
if (ongoing.value) {
abortLastSearch();
ongoing.value = false;
} else {
event.stopPropagation();
event.preventDefault();
layoutStore.closeHovers();
}
};
const keyup = (event: KeyboardEvent) => {
if (event.key === "Escape") {
close(event);
return;
}
results.value.length = 0;
};
const init = (string: string) => {
prompt.value = `${string} `;
input.value !== null ? input.value.focus() : "";
};
const reset = () => {
abortLastSearch();
ongoing.value = false;
resultsCount.value = 50;
results.value = [];
};
const abortLastSearch = () => {
searchAbortController.abort();
};
const submit = async (event: Event) => {
event.preventDefault();
if (prompt.value === "") {
return;
}
let path = route.path;
if (!fileStore.isListing) {
path = url.removeLastDir(path) + "/";
}
ongoing.value = true;
try {
abortLastSearch();
searchAbortController = new AbortController();
results.value = [];
await search(path, prompt.value, searchAbortController.signal, (item) =>
results.value.push(item)
);
} catch (error: any) {
if (error instanceof StatusError && error.is_canceled) {
return;
}
$showError(error);
}
ongoing.value = false;
};
</script>

View File

@@ -1,194 +0,0 @@
<template>
<div
class="shell"
:class="{ ['shell--hidden']: !showShell }"
:style="{ height: `${this.shellHeight}em`, direction: 'ltr' }"
>
<div
@pointerdown="startDrag()"
@pointerup="stopDrag()"
class="shell__divider"
:style="this.shellDrag ? { background: `${checkTheme()}` } : ''"
></div>
<div @click="focus" class="shell__content" ref="scrollable">
<div v-for="(c, index) in content" :key="index" class="shell__result">
<div class="shell__prompt">
<i class="material-icons">chevron_right</i>
</div>
<pre class="shell__text">{{ c.text }}</pre>
</div>
<div
class="shell__result"
:class="{ 'shell__result--hidden': !canInput }"
>
<div class="shell__prompt">
<i class="material-icons">chevron_right</i>
</div>
<pre
tabindex="0"
ref="input"
class="shell__text"
:contenteditable="true"
@keydown.prevent.arrow-up="historyUp"
@keydown.prevent.arrow-down="historyDown"
@keypress.prevent.enter="submit"
/>
</div>
</div>
<div
@pointerup="stopDrag()"
class="shell__overlay"
v-show="this.shellDrag"
></div>
</div>
</template>
<script>
import { mapState, mapActions } from "pinia";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { commands } from "@/api";
import { throttle } from "lodash-es";
import { theme } from "@/utils/constants";
export default {
name: "shell",
computed: {
...mapState(useLayoutStore, ["showShell"]),
...mapState(useFileStore, ["isFiles"]),
path: function () {
if (this.isFiles) {
return this.$route.path;
}
return "";
},
},
data: () => ({
content: [],
history: [],
historyPos: 0,
canInput: true,
shellDrag: false,
shellHeight: 25,
fontsize: parseFloat(getComputedStyle(document.documentElement).fontSize),
}),
mounted() {
window.addEventListener("resize", this.resize);
},
beforeUnmount() {
window.removeEventListener("resize", this.resize);
},
methods: {
...mapActions(useLayoutStore, ["toggleShell"]),
checkTheme() {
if (theme == "dark") {
return "rgba(255, 255, 255, 0.4)";
}
return "rgba(127, 127, 127, 0.4)";
},
startDrag() {
document.addEventListener("pointermove", this.handleDrag);
this.shellDrag = true;
},
stopDrag() {
document.removeEventListener("pointermove", this.handleDrag);
this.shellDrag = false;
},
handleDrag: throttle(function (event) {
const top = window.innerHeight / this.fontsize - 4;
const userPos = (window.innerHeight - event.clientY) / this.fontsize;
const bottom =
2.25 +
document.querySelector(".shell__divider").offsetHeight / this.fontsize;
if (userPos <= top && userPos >= bottom) {
this.shellHeight = userPos.toFixed(2);
}
}, 32),
resize: throttle(function () {
const top = window.innerHeight / this.fontsize - 4;
const bottom =
2.25 +
document.querySelector(".shell__divider").offsetHeight / this.fontsize;
if (this.shellHeight > top) {
this.shellHeight = top;
} else if (this.shellHeight < bottom) {
this.shellHeight = bottom;
}
}, 32),
scroll: function () {
this.$refs.scrollable.scrollTop = this.$refs.scrollable.scrollHeight;
},
focus: function () {
this.$refs.input.focus();
},
historyUp() {
if (this.historyPos > 0) {
this.$refs.input.innerText = this.history[--this.historyPos];
this.focus();
}
},
historyDown() {
if (this.historyPos >= 0 && this.historyPos < this.history.length - 1) {
this.$refs.input.innerText = this.history[++this.historyPos];
this.focus();
} else {
this.historyPos = this.history.length;
this.$refs.input.innerText = "";
}
},
submit: function (event) {
const cmd = event.target.innerText.trim();
if (cmd === "") {
return;
}
if (cmd === "clear") {
this.content = [];
event.target.innerHTML = "";
return;
}
if (cmd === "exit") {
event.target.innerHTML = "";
this.toggleShell();
return;
}
this.canInput = false;
event.target.innerHTML = "";
const results = {
text: `${cmd}\n\n`,
};
this.history.push(cmd);
this.historyPos = this.history.length;
this.content.push(results);
commands(
this.path,
cmd,
(event) => {
results.text += `${event.data}\n`;
this.scroll();
},
() => {
results.text = results.text
.replace(/\u001b\[[0-9;]+m/g, "") // Filter ANSI color for now
.trimEnd();
this.canInput = true;
this.$refs.input.focus();
this.scroll();
}
);
},
},
};
</script>

View File

@@ -1,222 +0,0 @@
<template>
<div v-show="active" @click="closeHovers" class="overlay"></div>
<nav :class="{ active }">
<template v-if="isLoggedIn">
<button @click="toAccountSettings" class="action">
<i class="material-icons">person</i>
<span>{{ user.username }}</span>
</button>
<button
class="action"
@click="toRoot"
:aria-label="$t('sidebar.myFiles')"
:title="$t('sidebar.myFiles')"
>
<i class="material-icons">folder</i>
<span>{{ $t("sidebar.myFiles") }}</span>
</button>
<div v-if="user.perm.create">
<button
@click="showHover('newDir')"
class="action"
:aria-label="$t('sidebar.newFolder')"
:title="$t('sidebar.newFolder')"
>
<i class="material-icons">create_new_folder</i>
<span>{{ $t("sidebar.newFolder") }}</span>
</button>
<button
@click="showHover('newFile')"
class="action"
:aria-label="$t('sidebar.newFile')"
:title="$t('sidebar.newFile')"
>
<i class="material-icons">note_add</i>
<span>{{ $t("sidebar.newFile") }}</span>
</button>
</div>
<div v-if="user.perm.admin">
<button
class="action"
@click="toGlobalSettings"
:aria-label="$t('sidebar.settings')"
:title="$t('sidebar.settings')"
>
<i class="material-icons">settings_applications</i>
<span>{{ $t("sidebar.settings") }}</span>
</button>
</div>
<button
v-if="canLogout"
@click="logout"
class="action"
id="logout"
:aria-label="$t('sidebar.logout')"
:title="$t('sidebar.logout')"
>
<i class="material-icons">exit_to_app</i>
<span>{{ $t("sidebar.logout") }}</span>
</button>
</template>
<template v-else>
<router-link
v-if="!hideLoginButton"
class="action"
to="/login"
:aria-label="$t('sidebar.login')"
:title="$t('sidebar.login')"
>
<i class="material-icons">exit_to_app</i>
<span>{{ $t("sidebar.login") }}</span>
</router-link>
<router-link
v-if="signup"
class="action"
to="/login"
:aria-label="$t('sidebar.signup')"
:title="$t('sidebar.signup')"
>
<i class="material-icons">person_add</i>
<span>{{ $t("sidebar.signup") }}</span>
</router-link>
</template>
<div
class="credits"
v-if="isFiles && !disableUsedPercentage"
style="width: 90%; margin: 2em 2.5em 3em 2.5em"
>
<progress-bar :val="usage.usedPercentage" size="small"></progress-bar>
<br />
{{ usage.used }} of {{ usage.total }} used
</div>
<p class="credits">
<span>
<span v-if="disableExternal">File Browser</span>
<a
v-else
rel="noopener noreferrer"
target="_blank"
href="https://github.com/filebrowser/filebrowser"
>File Browser</a
>
<span> {{ " " }} {{ version }}</span>
</span>
<span>
<a @click="help">{{ $t("sidebar.help") }}</a>
</span>
</p>
</nav>
</template>
<script>
import { reactive } from "vue";
import { mapActions, mapState } from "pinia";
import { useAuthStore } from "@/stores/auth";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import * as auth from "@/utils/auth";
import {
version,
signup,
hideLoginButton,
disableExternal,
disableUsedPercentage,
noAuth,
logoutPage,
loginPage,
} from "@/utils/constants";
import { files as api } from "@/api";
import ProgressBar from "@/components/ProgressBar.vue";
import prettyBytes from "pretty-bytes";
const USAGE_DEFAULT = { used: "0 B", total: "0 B", usedPercentage: 0 };
export default {
name: "sidebar",
setup() {
const usage = reactive(USAGE_DEFAULT);
return { usage, usageAbortController: new AbortController() };
},
components: {
ProgressBar,
},
inject: ["$showError"],
computed: {
...mapState(useAuthStore, ["user", "isLoggedIn"]),
...mapState(useFileStore, ["isFiles", "reload"]),
...mapState(useLayoutStore, ["currentPromptName"]),
active() {
return this.currentPromptName === "sidebar";
},
signup: () => signup,
hideLoginButton: () => hideLoginButton,
version: () => version,
disableExternal: () => disableExternal,
disableUsedPercentage: () => disableUsedPercentage,
canLogout: () => !noAuth && (loginPage || logoutPage !== "/login"),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers", "showHover"]),
abortOngoingFetchUsage() {
this.usageAbortController.abort();
},
async fetchUsage() {
const path = this.$route.path.endsWith("/")
? this.$route.path
: this.$route.path + "/";
let usageStats = USAGE_DEFAULT;
if (this.disableUsedPercentage) {
return Object.assign(this.usage, usageStats);
}
try {
this.abortOngoingFetchUsage();
this.usageAbortController = new AbortController();
const usage = await api.usage(path, this.usageAbortController.signal);
usageStats = {
used: prettyBytes(usage.used, { binary: true }),
total: prettyBytes(usage.total, { binary: true }),
usedPercentage: Math.round((usage.used / usage.total) * 100),
};
} finally {
return Object.assign(this.usage, usageStats);
}
},
toRoot() {
this.$router.push({ path: "/files" });
this.closeHovers();
},
toAccountSettings() {
this.$router.push({ path: "/settings/profile" });
this.closeHovers();
},
toGlobalSettings() {
this.$router.push({ path: "/settings/global" });
this.closeHovers();
},
help() {
this.showHover("help");
},
logout: auth.logout,
},
watch: {
$route: {
handler(to) {
if (to.path.includes("/files")) {
this.fetchUsage();
}
},
immediate: true,
},
},
unmounted() {
this.abortOngoingFetchUsage();
},
};
</script>

View File

@@ -0,0 +1,69 @@
/**
* Skeleton loaders for loading states
*/
export function SkeletonRow() {
return (
<div className="animate-pulse space-y-2 rounded-lg border border-gray-200 p-4 dark:border-slate-700">
<div className="flex items-center gap-4">
<div className="h-4 w-4 rounded bg-gray-300 dark:bg-slate-600" />
<div className="h-4 flex-1 rounded bg-gray-300 dark:bg-slate-600" />
<div className="h-4 w-20 rounded bg-gray-300 dark:bg-slate-600" />
</div>
</div>
);
}
export function SkeletonTable({ rows = 5 }: { rows?: number }) {
return (
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-slate-700">
<table className="w-full">
<thead className="border-b border-gray-200 bg-gray-50 dark:border-slate-600 dark:bg-slate-800">
<tr>
<th className="px-4 py-3">
<div className="h-4 w-4 rounded bg-gray-300 dark:bg-slate-600" />
</th>
<th className="px-4 py-3">
<div className="h-4 w-24 rounded bg-gray-300 dark:bg-slate-600" />
</th>
<th className="px-4 py-3">
<div className="h-4 w-16 rounded bg-gray-300 dark:bg-slate-600" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
{Array.from({ length: rows }).map((_, i) => (
<tr key={i} className="animate-pulse">
<td className="px-4 py-3">
<div className="h-4 w-4 rounded bg-gray-200 dark:bg-slate-700" />
</td>
<td className="px-4 py-3">
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-slate-700" />
</td>
<td className="px-4 py-3">
<div className="h-4 w-20 rounded bg-gray-200 dark:bg-slate-700" />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function SkeletonCard() {
return (
<div className="animate-pulse space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800">
<div className="h-6 w-40 rounded bg-gray-300 dark:bg-slate-600" />
<div className="space-y-3">
<div className="h-4 w-full rounded bg-gray-200 dark:bg-slate-700" />
<div className="h-4 w-5/6 rounded bg-gray-200 dark:bg-slate-700" />
<div className="h-4 w-4/6 rounded bg-gray-200 dark:bg-slate-700" />
</div>
</div>
);
}
export function SkeletonText() {
return <div className="h-4 w-full animate-pulse rounded bg-gray-200 dark:bg-slate-700" />;
}

View File

@@ -1,366 +0,0 @@
<template>
<div class="csv-viewer">
<div class="csv-header">
<div class="header-select">
<label for="columnSeparator">{{ $t("files.columnSeparator") }}</label>
<select
id="columnSeparator"
class="input input--block"
v-model="columnSeparator"
>
<option :value="[',']">
{{ $t("files.csvSeparators.comma") }}
</option>
<option :value="[';']">
{{ $t("files.csvSeparators.semicolon") }}
</option>
<option :value="[',', ';']">
{{ $t("files.csvSeparators.both") }}
</option>
</select>
</div>
<div class="header-select" v-if="isEncodedContent">
<label for="fileEncoding">{{ $t("files.fileEncoding") }}</label>
<DropdownModal
v-model="isEncondingDropdownOpen"
:close-on-click="false"
>
<div>
<span class="selected-encoding">{{ selectedEncoding }}</span>
</div>
<template v-slot:list>
<input
v-model="encodingSearch"
:placeholder="$t('search.search')"
class="input input--block"
name="encoding"
/>
<div class="encoding-list">
<div v-if="encodingList.length == 0" class="message">
<i class="material-icons">sentiment_dissatisfied</i>
<span>{{ $t("files.lonely") }}</span>
</div>
<button
v-for="encoding in encodingList"
:value="encoding"
:key="encoding"
class="encoding-button"
@click="selectedEncoding = encoding"
>
{{ encoding }}
</button>
</div>
</template>
</DropdownModal>
</div>
</div>
<div v-if="displayError" class="csv-error">
<i class="material-icons">error</i>
<p>{{ displayError }}</p>
</div>
<div v-else-if="parsed.headers.length === 0" class="csv-empty">
<i class="material-icons">description</i>
<p>{{ $t("files.lonely") }}</p>
</div>
<div v-else class="csv-table-container" @wheel.stop @touchmove.stop>
<table class="csv-table">
<thead>
<tr>
<th v-for="(header, index) in parsed.headers" :key="index">
{{ header || `Column ${index + 1}` }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in parsed.rows" :key="rowIndex">
<td v-for="(cell, cellIndex) in row" :key="cellIndex">
{{ cell }}
</td>
</tr>
</tbody>
</table>
<div class="csv-footer">
<div class="csv-info" v-if="parsed.rows.length > 100">
<i class="material-icons">info</i>
<span>
{{ $t("files.showingRows", { count: parsed.rows.length }) }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from "vue";
import { parse } from "csv-parse/browser/esm";
import { useI18n } from "vue-i18n";
import { availableEncodings, decode } from "@/utils/encodings";
import DropdownModal from "../DropdownModal.vue";
const { t } = useI18n({});
interface Props {
content: ArrayBuffer | string;
error?: string;
}
const props = withDefaults(defineProps<Props>(), {
error: "",
});
const isEncondingDropdownOpen = ref(false);
const encodingSearch = ref<string>("");
const encodingList = computed(() => {
return availableEncodings.filter((e) =>
e.toLowerCase().includes(encodingSearch.value.toLowerCase())
);
});
const columnSeparator = ref([",", ";"]);
const selectedEncoding = ref("utf-8");
const parsed = ref<CsvData>({ headers: [], rows: [] });
const displayError = ref<string | null>(null);
const isEncodedContent = computed(() => {
return props.content instanceof ArrayBuffer;
});
watchEffect(() => {
if (props.content !== "" && columnSeparator.value.length > 0) {
const content = isEncodedContent.value
? decode(props.content as ArrayBuffer, selectedEncoding.value)
: props.content;
parse(
content as string,
{ delimiter: columnSeparator.value, skip_empty_lines: true },
(error, output) => {
if (error) {
console.error("Failed to parse CSV:", error);
parsed.value = { headers: [], rows: [] };
displayError.value = t("files.csvLoadFailed", {
error: error.toString(),
});
} else {
parsed.value = {
headers: output[0],
rows: output.slice(1),
};
displayError.value = null;
}
}
);
}
});
watch(selectedEncoding, () => {
isEncondingDropdownOpen.value = false;
encodingSearch.value = "";
});
</script>
<style scoped>
.csv-viewer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--surfacePrimary);
color: var(--textSecondary);
padding: 1rem;
padding-top: 4em;
box-sizing: border-box;
}
.csv-error,
.csv-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 1rem;
color: var(--textPrimary);
}
.csv-error i,
.csv-empty i {
font-size: 4rem;
opacity: 0.5;
}
.csv-error p,
.csv-empty p {
font-size: 1.1rem;
margin: 0;
}
.csv-table-container {
flex: 1;
overflow: auto;
background-color: var(--surfacePrimary);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* Scrollbar styling for better visibility */
.csv-table-container::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.csv-table-container::-webkit-scrollbar-track {
background: var(--background);
border-radius: 4px;
}
.csv-table-container::-webkit-scrollbar-thumb {
background: var(--borderSecondary);
border-radius: 4px;
}
.csv-table-container::-webkit-scrollbar-thumb:hover {
background: var(--textPrimary);
}
.csv-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
background-color: var(--surfacePrimary);
}
.csv-table thead {
position: sticky;
top: 0;
z-index: 10;
background-color: var(--surfaceSecondary);
}
.csv-table th {
padding: 0.875rem 1rem;
text-align: left;
font-weight: 600;
border-bottom: 2px solid var(--borderSecondary);
background-color: var(--surfaceSecondary);
white-space: nowrap;
color: var(--textSecondary);
font-size: 0.875rem;
}
.csv-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--borderPrimary);
white-space: nowrap;
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
color: var(--textSecondary);
}
.csv-table tbody tr:nth-child(even) {
background-color: var(--background);
}
.csv-table tbody tr:hover {
background-color: var(--hover);
transition: background-color 0.15s ease;
}
.csv-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.5rem;
}
.csv-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
margin-top: 0.5rem;
background-color: var(--surfaceSecondary);
border-radius: 4px;
border-left: 3px solid var(--blue);
color: var(--textSecondary);
font-size: 0.875rem;
}
.csv-header {
display: flex;
justify-content: space-between;
padding: 0.25rem;
}
.header-select {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-direction: column;
@media (width >= 640px) {
flex-direction: row;
}
}
.header-select > label {
font-size: small;
@media (width >= 640px) {
max-width: 70px;
}
}
.header-select > select,
.header-select > div {
margin-bottom: 0;
}
.csv-info i {
font-size: 1.2rem;
color: var(--blue);
}
.encoding-list {
max-height: 300px;
min-width: 120px;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.encoding-button {
background-color: transparent;
border: none;
outline: none;
padding: 0.25rem 0.5rem;
color: var(--textPrimary);
text-align: left;
cursor: pointer;
border-radius: 0.2rem;
white-space: nowrap;
display: block;
width: 100%;
}
.encoding-button:hover {
background-color: var(--surfaceSecondary);
}
.selected-encoding {
white-space: nowrap;
text-overflow: ellipsis;
}
.message {
font-size: 1.25em;
}
</style>

View File

@@ -1,330 +0,0 @@
<template>
<div
class="image-ex-container"
ref="container"
@touchstart="touchStart"
@touchmove="touchMove"
@dblclick="zoomAuto"
@mousedown="mousedownStart"
@mousemove="mouseMove"
@mouseup="mouseUp"
@wheel="wheelMove"
>
<img class="image-ex-img image-ex-img-center" ref="imgex" @load="onLoad" />
</div>
</template>
<script setup lang="ts">
import { throttle } from "lodash-es";
import UTIF from "utif";
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
interface IProps {
src: string;
moveDisabledTime?: number;
classList?: any[];
zoomStep?: number;
}
const props = withDefaults(defineProps<IProps>(), {
moveDisabledTime: () => 200,
classList: () => [],
zoomStep: () => 0.25,
});
const scale = ref<number>(1);
const lastX = ref<number | null>(null);
const lastY = ref<number | null>(null);
const inDrag = ref<boolean>(false);
const touches = ref<number>(0);
const lastTouchDistance = ref<number | null>(0);
const moveDisabled = ref<boolean>(false);
const disabledTimer = ref<number | null>(null);
const imageLoaded = ref<boolean>(false);
const position = ref<{
center: { x: number; y: number };
relative: { x: number; y: number };
}>({
center: { x: 0, y: 0 },
relative: { x: 0, y: 0 },
});
const maxScale = ref<number>(4);
const minScale = ref<number>(0.25);
// Refs
const imgex = ref<HTMLImageElement | null>(null);
const container = ref<HTMLDivElement | null>(null);
onMounted(() => {
if (!decodeUTIF() && imgex.value !== null) {
imgex.value.src = props.src;
}
props.classList.forEach((className) =>
container.value !== null ? container.value.classList.add(className) : ""
);
if (container.value === null) {
return;
}
// set width and height if they are zero
if (getComputedStyle(container.value).width === "0px") {
container.value.style.width = "100%";
}
if (getComputedStyle(container.value).height === "0px") {
container.value.style.height = "100%";
}
window.addEventListener("resize", onResize);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", onResize);
document.removeEventListener("mouseup", onMouseUp);
});
watch(
() => props.src,
() => {
if (!decodeUTIF() && imgex.value !== null) {
imgex.value.src = props.src;
}
scale.value = 1;
setZoom();
setCenter();
}
);
// Modified from UTIF.replaceIMG
const decodeUTIF = () => {
const sufs = ["tif", "tiff", "dng", "cr2", "nef"];
if (document?.location?.pathname === undefined) {
return;
}
const suff =
document.location.pathname.split(".")?.pop()?.toLowerCase() ?? "";
if (sufs.indexOf(suff) == -1) return false;
const xhr = new XMLHttpRequest();
UTIF._xhrs.push(xhr);
UTIF._imgs.push(imgex.value);
xhr.open("GET", props.src);
xhr.responseType = "arraybuffer";
xhr.onload = UTIF._imgLoaded;
xhr.send();
return true;
};
const onLoad = () => {
imageLoaded.value = true;
if (imgex.value === null) {
return;
}
imgex.value.classList.remove("image-ex-img-center");
setCenter();
imgex.value.classList.add("image-ex-img-ready");
document.addEventListener("mouseup", onMouseUp);
let realSize = imgex.value.naturalWidth;
let displaySize = imgex.value.offsetWidth;
// Image is in portrait orientation
if (imgex.value.naturalHeight > imgex.value.naturalWidth) {
realSize = imgex.value.naturalHeight;
displaySize = imgex.value.offsetHeight;
}
// Scale needed to display the image on full size
const fullScale = realSize / displaySize;
// Full size plus additional zoom
maxScale.value = fullScale + 4;
};
const onMouseUp = () => {
inDrag.value = false;
};
const onResize = throttle(function () {
if (imageLoaded.value) {
setCenter();
doMove(position.value.relative.x, position.value.relative.y);
}
}, 100);
const setCenter = () => {
if (container.value === null || imgex.value === null) {
return;
}
position.value.center.x = Math.floor(
(container.value.clientWidth - imgex.value.clientWidth) / 2
);
position.value.center.y = Math.floor(
(container.value.clientHeight - imgex.value.clientHeight) / 2
);
imgex.value.style.left = position.value.center.x + "px";
imgex.value.style.top = position.value.center.y + "px";
};
const mousedownStart = (event: MouseEvent) => {
if (event.button !== 0) return;
lastX.value = null;
lastY.value = null;
inDrag.value = true;
event.preventDefault();
};
const mouseMove = (event: MouseEvent) => {
if (!inDrag.value) return;
doMove(event.movementX, event.movementY);
event.preventDefault();
};
const mouseUp = (event: Event) => {
if (inDrag.value) {
event.preventDefault();
}
inDrag.value = false;
};
const touchStart = (event: TouchEvent) => {
lastX.value = null;
lastY.value = null;
lastTouchDistance.value = null;
if (event.targetTouches.length < 2) {
setTimeout(() => {
touches.value = 0;
}, 300);
touches.value++;
if (touches.value > 1) {
zoomAuto(event);
}
}
event.preventDefault();
};
const zoomAuto = (event: Event) => {
switch (scale.value) {
case 1:
scale.value = 2;
break;
case 2:
scale.value = 4;
break;
default:
case 4:
scale.value = 1;
setCenter();
break;
}
setZoom();
event.preventDefault();
};
const touchMove = (event: TouchEvent) => {
event.preventDefault();
if (lastX.value === null) {
lastX.value = event.targetTouches[0].pageX;
lastY.value = event.targetTouches[0].pageY;
return;
}
if (imgex.value === null) {
return;
}
const step = imgex.value.width / 5;
if (event.targetTouches.length === 2) {
moveDisabled.value = true;
if (disabledTimer.value) clearTimeout(disabledTimer.value);
disabledTimer.value = window.setTimeout(
() => (moveDisabled.value = false),
props.moveDisabledTime
);
const p1 = event.targetTouches[0];
const p2 = event.targetTouches[1];
const touchDistance = Math.sqrt(
Math.pow(p2.pageX - p1.pageX, 2) + Math.pow(p2.pageY - p1.pageY, 2)
);
if (!lastTouchDistance.value) {
lastTouchDistance.value = touchDistance;
return;
}
scale.value += (touchDistance - lastTouchDistance.value) / step;
lastTouchDistance.value = touchDistance;
setZoom();
} else if (event.targetTouches.length === 1) {
if (moveDisabled.value) return;
const x = event.targetTouches[0].pageX - (lastX.value ?? 0);
const y = event.targetTouches[0].pageY - (lastY.value ?? 0);
if (Math.abs(x) >= step && Math.abs(y) >= step) return;
lastX.value = event.targetTouches[0].pageX;
lastY.value = event.targetTouches[0].pageY;
doMove(x, y);
}
};
const doMove = (x: number, y: number) => {
if (imgex.value === null) {
return;
}
const style = imgex.value.style;
const posX = pxStringToNumber(style.left) + x;
const posY = pxStringToNumber(style.top) + y;
style.left = posX + "px";
style.top = posY + "px";
position.value.relative.x = Math.abs(position.value.center.x - posX);
position.value.relative.y = Math.abs(position.value.center.y - posY);
if (posX < position.value.center.x) {
position.value.relative.x = position.value.relative.x * -1;
}
if (posY < position.value.center.y) {
position.value.relative.y = position.value.relative.y * -1;
}
};
const wheelMove = (event: WheelEvent) => {
scale.value += -Math.sign(event.deltaY) * props.zoomStep;
setZoom();
};
const setZoom = () => {
scale.value = scale.value < minScale.value ? minScale.value : scale.value;
scale.value = scale.value > maxScale.value ? maxScale.value : scale.value;
if (imgex.value !== null)
imgex.value.style.transform = `scale(${scale.value})`;
};
const pxStringToNumber = (style: string) => {
return +style.replace("px", "");
};
</script>
<style>
.image-ex-container {
margin: auto;
overflow: hidden;
position: relative;
}
.image-ex-img {
position: absolute;
}
.image-ex-img-center {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
position: absolute;
transition: none;
}
.image-ex-img-ready {
left: 0;
top: 0;
transition: transform 0.1s ease;
}
</style>

View File

@@ -0,0 +1,19 @@
import type { IFile } from "@/types";
export interface FileListTableProps {
files: IFile[];
onFileClick?: (file: IFile) => void;
onDelete?: (file: IFile) => void;
selectedFiles?: Set<string>;
onSelectChange?: (selected: Set<string>) => void;
}
export function FileListTable({
files,
onFileClick,
onDelete,
selectedFiles = new Set(),
onSelectChange,
}: FileListTableProps) {
return <div>{/* Component defined inline to avoid import issues */}</div>;
}

View File

@@ -1,407 +0,0 @@
<template>
<div
class="item"
role="button"
tabindex="0"
:draggable="isDraggable"
@dragstart="dragStart"
@dragover="dragOver"
@drop="drop"
@click="itemClick"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
@mouseleave="handleMouseLeave"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
@touchcancel="handleTouchCancel"
@touchmove="handleTouchMove"
:data-dir="isDir"
:data-type="type"
:aria-label="name"
:aria-selected="isSelected"
:data-ext="getExtension(name).toLowerCase()"
@contextmenu="contextMenu"
>
<div>
<img
v-if="!readOnly && type === 'image' && isThumbsEnabled"
v-lazy="thumbnailUrl"
/>
<i v-else class="material-icons"></i>
</div>
<div>
<p class="name">{{ name }}</p>
<p v-if="isDir" class="size" data-order="-1">&mdash;</p>
<p v-else class="size" :data-order="humanSize()">{{ humanSize() }}</p>
<p class="modified">
<time :datetime="modified">{{ humanTime() }}</time>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from "@/stores/auth";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { enableThumbs } from "@/utils/constants";
import { filesize } from "@/utils";
import dayjs from "dayjs";
import { files as api } from "@/api";
import * as upload from "@/utils/upload";
import { computed, inject, ref } from "vue";
import { useRouter } from "vue-router";
const touches = ref<number>(0);
const longPressTimer = ref<number | null>(null);
const longPressTriggered = ref<boolean>(false);
const longPressDelay = ref<number>(500);
const startPosition = ref<{ x: number; y: number } | null>(null);
const moveThreshold = ref<number>(10);
const $showError = inject<IToastError>("$showError")!;
const router = useRouter();
const props = defineProps<{
name: string;
isDir: boolean;
url: string;
type: string;
size: number;
modified: string;
index: number;
readOnly?: boolean;
path?: string;
}>();
const authStore = useAuthStore();
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
const singleClick = computed(
() => !props.readOnly && authStore.user?.singleClick
);
const isSelected = computed(
() => fileStore.selected.indexOf(props.index) !== -1
);
const isDraggable = computed(
() => !props.readOnly && authStore.user?.perm.rename
);
const canDrop = computed(() => {
if (!props.isDir || props.readOnly) return false;
for (const i of fileStore.selected) {
if (fileStore.req?.items[i].url === props.url) {
return false;
}
}
return true;
});
const thumbnailUrl = computed(() => {
const file = {
path: props.path,
modified: props.modified,
};
return api.getPreviewURL(file as Resource, "thumb");
});
const isThumbsEnabled = computed(() => {
return enableThumbs;
});
const humanSize = () => {
return props.type == "invalid_link" ? "invalid link" : filesize(props.size);
};
const humanTime = () => {
if (!props.readOnly && authStore.user?.dateFormat) {
return dayjs(props.modified).format("L LT");
}
return dayjs(props.modified).fromNow();
};
const dragStart = () => {
if (fileStore.selectedCount === 0) {
fileStore.selected.push(props.index);
return;
}
if (!isSelected.value) {
fileStore.selected = [];
fileStore.selected.push(props.index);
}
};
const dragOver = (event: Event) => {
if (!canDrop.value) return;
event.preventDefault();
let el = event.target as HTMLElement | null;
if (el !== null) {
for (let i = 0; i < 5; i++) {
if (!el?.classList.contains("item")) {
el = el?.parentElement ?? null;
}
}
if (el !== null) el.style.opacity = "1";
}
};
const drop = async (event: Event) => {
if (!canDrop.value) return;
event.preventDefault();
if (fileStore.selectedCount === 0) return;
let el = event.target as HTMLElement | null;
for (let i = 0; i < 5; i++) {
if (el !== null && !el.classList.contains("item")) {
el = el.parentElement;
}
}
const items: any[] = [];
for (const i of fileStore.selected) {
if (fileStore.req) {
items.push({
from: fileStore.req?.items[i].url,
to: props.url + encodeURIComponent(fileStore.req?.items[i].name),
name: fileStore.req?.items[i].name,
size: fileStore.req?.items[i].size,
modified: fileStore.req?.items[i].modified,
overwrite: false,
rename: false,
});
}
}
// Get url from ListingItem instance
if (el === null) {
return;
}
const path = el.__vue__.url;
const baseItems = (await api.fetch(path)).items;
const action = (overwrite?: boolean, rename?: boolean) => {
api
.move(items, overwrite, rename)
.then(() => {
fileStore.reload = true;
})
.catch($showError);
};
const conflict = upload.checkConflict(items, baseItems);
if (conflict.length > 0) {
layoutStore.showHover({
prompt: "resolve-conflict",
props: {
conflict: conflict,
},
confirm: (event: Event, result: Array<ConflictingResource>) => {
event.preventDefault();
layoutStore.closeHovers();
for (let i = result.length - 1; i >= 0; i--) {
const item = result[i];
if (item.checked.length == 2) {
items[item.index].rename = true;
} else if (item.checked.length == 1 && item.checked[0] == "origin") {
items[item.index].overwrite = true;
} else {
items.splice(item.index, 1);
}
}
if (items.length > 0) {
action();
}
},
});
return;
}
action(false, false);
};
const itemClick = (event: Event | KeyboardEvent) => {
// If long press was triggered, prevent normal click behavior
if (longPressTriggered.value) {
longPressTriggered.value = false;
return;
}
if (
singleClick.value &&
!(event as KeyboardEvent).ctrlKey &&
!(event as KeyboardEvent).metaKey &&
!(event as KeyboardEvent).shiftKey &&
!fileStore.multiple
)
open();
else click(event);
};
const contextMenu = (event: MouseEvent) => {
event.preventDefault();
if (
fileStore.selected.length === 0 ||
event.ctrlKey ||
fileStore.selected.indexOf(props.index) === -1
) {
click(event);
}
};
const click = (event: Event | KeyboardEvent) => {
if (!singleClick.value && fileStore.selectedCount !== 0)
event.preventDefault();
setTimeout(() => {
touches.value = 0;
}, 300);
touches.value++;
if (touches.value > 1) {
open();
}
if (fileStore.selected.indexOf(props.index) !== -1) {
if (
(event as KeyboardEvent).ctrlKey ||
(event as KeyboardEvent).metaKey ||
fileStore.multiple
) {
fileStore.removeSelected(props.index);
} else {
fileStore.selected = [props.index];
}
return;
}
if ((event as KeyboardEvent).shiftKey && fileStore.selected.length > 0) {
let fi = 0;
let la = 0;
if (props.index > fileStore.selected[0]) {
fi = fileStore.selected[0] + 1;
la = props.index;
} else {
fi = props.index;
la = fileStore.selected[0] - 1;
}
for (; fi <= la; fi++) {
if (fileStore.selected.indexOf(fi) == -1) {
fileStore.selected.push(fi);
}
}
return;
}
if (
!(event as KeyboardEvent).ctrlKey &&
!(event as KeyboardEvent).metaKey &&
!fileStore.multiple
) {
fileStore.selected = [];
}
fileStore.selected.push(props.index);
};
const open = () => {
router.push({ path: props.url });
};
const getExtension = (fileName: string): string => {
const lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex === -1) {
return fileName;
}
return fileName.substring(lastDotIndex);
};
// Long-press helper functions
const startLongPress = (clientX: number, clientY: number) => {
startPosition.value = { x: clientX, y: clientY };
longPressTimer.value = window.setTimeout(() => {
handleLongPress();
}, longPressDelay.value);
};
const cancelLongPress = () => {
if (longPressTimer.value !== null) {
window.clearTimeout(longPressTimer.value);
longPressTimer.value = null;
}
startPosition.value = null;
};
const handleLongPress = () => {
if (singleClick.value) {
longPressTriggered.value = true;
click(new Event("longpress"));
}
cancelLongPress();
};
const checkMovement = (clientX: number, clientY: number): boolean => {
if (!startPosition.value) return false;
const deltaX = Math.abs(clientX - startPosition.value.x);
const deltaY = Math.abs(clientY - startPosition.value.y);
return deltaX > moveThreshold.value || deltaY > moveThreshold.value;
};
// Event handlers
const handleMouseDown = (event: MouseEvent) => {
if (event.button === 0) {
startLongPress(event.clientX, event.clientY);
}
};
const handleMouseUp = () => {
cancelLongPress();
};
const handleMouseLeave = () => {
cancelLongPress();
};
const handleTouchStart = (event: TouchEvent) => {
if (event.touches.length === 1) {
const touch = event.touches[0];
startLongPress(touch.clientX, touch.clientY);
}
};
const handleTouchEnd = () => {
cancelLongPress();
};
const handleTouchCancel = () => {
cancelLongPress();
};
const handleTouchMove = (event: TouchEvent) => {
if (event.touches.length === 1 && startPosition.value) {
const touch = event.touches[0];
if (checkMovement(touch.clientX, touch.clientY)) {
cancelLongPress();
}
}
};
</script>

View File

@@ -1,186 +0,0 @@
<template>
<video ref="videoPlayer" class="video-max video-js" controls preload="auto">
<source />
<track
kind="subtitles"
v-for="(sub, index) in subtitles"
:key="index"
:src="sub"
:label="subLabel(sub)"
:default="index === 0"
/>
<p class="vjs-no-js">
Sorry, your browser doesn't support embedded videos, but don't worry, you
can <a :href="source">download it</a>
and watch it with your favorite video player!
</p>
</video>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from "vue";
import videojs from "video.js";
import type Player from "video.js/dist/types/player";
import "videojs-mobile-ui";
import "videojs-hotkeys";
import "video.js/dist/video-js.min.css";
import "videojs-mobile-ui/dist/videojs-mobile-ui.css";
const videoPlayer = ref<HTMLElement | null>(null);
const player = ref<Player | null>(null);
const props = withDefaults(
defineProps<{
source: string;
subtitles?: string[];
options?: any;
}>(),
{
options: {},
}
);
const source = ref(props.source);
const sourceType = ref("");
nextTick(() => {
initVideoPlayer();
});
onMounted(() => {});
onBeforeUnmount(() => {
if (player.value) {
player.value.dispose();
player.value = null;
}
});
const initVideoPlayer = async () => {
try {
const lang = document.documentElement.lang;
const languagePack = await (
languageImports[lang] || languageImports.en
)?.();
const code = languageImports[lang] ? lang : "en";
videojs.addLanguage(code, languagePack.default);
sourceType.value = "";
//
sourceType.value = getSourceType(source.value);
const srcOpt = { sources: { src: props.source, type: sourceType.value } };
//Supporting localized language display.
const langOpt = { language: code };
// support for playback at different speeds.
const playbackRatesOpt = { playbackRates: [0.5, 1, 1.5, 2, 2.5, 3] };
const options = getOptions(
props.options,
langOpt,
srcOpt,
playbackRatesOpt
);
player.value = videojs(videoPlayer.value!, options, () => {});
// TODO: need to test on mobile
// @ts-expect-error no ts definition for mobileUi
player.value!.mobileUi();
} catch (error) {
console.error("Error initializing video player:", error);
}
};
const getOptions = (...srcOpt: any[]) => {
const options = {
controlBar: {
skipButtons: {
forward: 5,
backward: 5,
},
},
html5: {
nativeTextTracks: false,
},
plugins: {
hotkeys: {
volumeStep: 0.1,
seekStep: 10,
enableModifiersForNumbers: false,
},
},
};
return videojs.obj.merge(options, ...srcOpt);
};
// Attempting to fix the issue of being unable to play .MKV format video files
const getSourceType = (source: string) => {
const fileExtension = source ? source.split("?")[0].split(".").pop() : "";
if (fileExtension?.toLowerCase() === "mkv") {
return "video/mp4";
}
return "";
};
const subLabel = (subUrl: string) => {
let url: URL;
try {
url = new URL(subUrl);
} catch {
// treat it as a relative url
// we only need this for filename
url = new URL(subUrl, window.location.origin);
}
const label = decodeURIComponent(
url.pathname
.split("/")
.pop()!
.replace(/\.[^/.]+$/, "")
);
return label;
};
interface LanguageImports {
[key: string]: () => Promise<any>;
}
const languageImports: LanguageImports = {
ar: () => import("video.js/dist/lang/ar.json"),
bg: () => import("video.js/dist/lang/bg.json"),
cs: () => import("video.js/dist/lang/cs.json"),
de: () => import("video.js/dist/lang/de.json"),
el: () => import("video.js/dist/lang/el.json"),
en: () => import("video.js/dist/lang/en.json"),
es: () => import("video.js/dist/lang/es.json"),
fr: () => import("video.js/dist/lang/fr.json"),
he: () => import("video.js/dist/lang/he.json"),
hr: () => import("video.js/dist/lang/hr.json"),
hu: () => import("video.js/dist/lang/hu.json"),
it: () => import("video.js/dist/lang/it.json"),
ja: () => import("video.js/dist/lang/ja.json"),
ko: () => import("video.js/dist/lang/ko.json"),
lv: () => import("video.js/dist/lang/lv.json"),
nb: () => import("video.js/dist/lang/nb.json"),
nl: () => import("video.js/dist/lang/nl.json"),
"nl-be": () => import("video.js/dist/lang/nl.json"),
pl: () => import("video.js/dist/lang/pl.json"),
"pt-br": () => import("video.js/dist/lang/pt-BR.json"),
"pt-pt": () => import("video.js/dist/lang/pt-PT.json"),
ro: () => import("video.js/dist/lang/ro.json"),
ru: () => import("video.js/dist/lang/ru.json"),
sk: () => import("video.js/dist/lang/sk.json"),
tr: () => import("video.js/dist/lang/tr.json"),
uk: () => import("video.js/dist/lang/uk.json"),
vi: () => import("video.js/dist/lang/vi.json"),
"zh-cn": () => import("video.js/dist/lang/zh-CN.json"),
"zh-tw": () => import("video.js/dist/lang/zh-TW.json"),
};
</script>
<style scoped>
.video-max {
width: 100%;
height: 100%;
}
</style>

View File

@@ -1,32 +0,0 @@
<template>
<button @click="action" :aria-label="label" :title="label" class="action">
<i class="material-icons">{{ icon }}</i>
<span>{{ label }}</span>
<span v-if="counter && counter > 0" class="counter">{{ counter }}</span>
</button>
</template>
<script setup lang="ts">
import { useLayoutStore } from "@/stores/layout";
const props = defineProps<{
icon?: string;
label?: string;
counter?: number;
show?: string;
}>();
const emit = defineEmits<{
(e: "action"): any;
}>();
const layoutStore = useLayoutStore();
const action = () => {
if (props.show) {
layoutStore.showHover(props.show);
}
emit("action");
};
</script>

View File

@@ -1,59 +0,0 @@
<template>
<header>
<img v-if="showLogo" :src="logoURL" />
<Action
v-if="showMenu"
class="menu-button"
icon="menu"
:label="t('buttons.toggleSidebar')"
@action="layoutStore.showHover('sidebar')"
/>
<slot />
<div
id="dropdown"
:class="{ active: layoutStore.currentPromptName === 'more' }"
>
<slot name="actions" />
</div>
<Action
v-if="ifActionsSlot"
id="more"
icon="more_vert"
:label="t('buttons.more')"
@action="layoutStore.showHover('more')"
/>
<div
class="overlay"
v-show="layoutStore.currentPromptName == 'more'"
@click="layoutStore.closeHovers"
/>
</header>
</template>
<script setup lang="ts">
import { useLayoutStore } from "@/stores/layout";
import { logoURL } from "@/utils/constants";
import Action from "@/components/header/Action.vue";
import { computed, useSlots } from "vue";
import { useI18n } from "vue-i18n";
defineProps<{
showLogo?: boolean;
showMenu?: boolean;
}>();
const layoutStore = useLayoutStore();
const slots = useSlots();
const { t } = useI18n();
const ifActionsSlot = computed(() => (slots.actions ? true : false));
</script>
<style></style>

View File

@@ -0,0 +1,68 @@
import { useAuth } from "@/context/AuthContext";
import { useToast } from "@/hooks/useToast";
import { Button } from "@/components/ui/Button";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
export interface HeaderProps {
title?: string;
onMenuClick?: () => void;
}
export function Header({ title, onMenuClick }: HeaderProps) {
const { user, logout } = useAuth();
const { showError } = useToast();
const { t } = useTranslation();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await logout();
navigate({ to: "/login" });
} catch (err) {
showError(err);
}
};
return (
<header className="border-b border-gray-200 bg-white dark:border-slate-700 dark:bg-slate-900">
<div className="flex items-center justify-between px-4 py-3 sm:px-6">
<div className="flex items-center gap-4">
{onMenuClick && (
<Button
variant="ghost"
size="sm"
onClick={onMenuClick}
className="md:hidden"
>
</Button>
)}
{title && (
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-50">
{title}
</h1>
)}
</div>
<div className="flex items-center gap-4">
{user && (
<>
<span className="text-sm text-gray-600 dark:text-gray-400">
{user.username}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="text-sm"
>
{t("auth.logout")}
</Button>
</>
)}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,36 @@
import { useState } from "react";
import { Header } from "./Header";
import { Sidebar } from "./Sidebar";
export interface LayoutShellProps {
title?: string;
children: React.ReactNode;
currentPath?: string;
}
export function LayoutShell({ title, children, currentPath }: LayoutShellProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen flex-col">
<Header
title={title}
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
/>
<div className="flex flex-1 overflow-hidden">
<Sidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
currentPath={currentPath}
/>
<main className="flex-1 overflow-auto">
<div className="container mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{children}
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { Link } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { cn } from "@/utils/cn";
export interface SidebarProps {
isOpen?: boolean;
onClose?: () => void;
currentPath?: string;
}
export function Sidebar({ isOpen = true, onClose, currentPath }: SidebarProps) {
const { t } = useTranslation();
const menuItems = [
{ label: t("files.name"), path: "/files" },
{ label: t("settings.title"), path: "/settings" },
];
return (
<aside
className={cn(
"fixed inset-y-0 left-0 z-40 w-64 border-r border-gray-200 bg-white dark:border-slate-700 dark:bg-slate-900 md:relative md:z-auto",
!isOpen && "hidden md:block"
)}
>
<nav className="space-y-2 p-4">
{menuItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={cn(
"block rounded-md px-4 py-2 text-sm font-medium transition-colors",
currentPath === item.path
? "bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-200"
: "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-slate-800"
)}
onClick={onClose}
>
{item.label}
</Link>
))}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,98 @@
import { useState } from "react";
import { Button } from "@/components/ui/Button";
export interface FileOperationDialogProps {
isOpen: boolean;
title: string;
label: string;
placeholder: string;
defaultValue?: string;
isLoading?: boolean;
onConfirm: (value: string) => Promise<void> | void;
onCancel: () => void;
isDestructive?: boolean;
}
export function FileOperationDialog({
isOpen,
title,
label,
placeholder,
defaultValue = "",
isLoading = false,
onConfirm,
onCancel,
isDestructive = false,
}: FileOperationDialogProps) {
const [value, setValue] = useState(defaultValue);
const [error, setError] = useState("");
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await onConfirm(value);
setValue("");
} catch (err) {
setError(
err instanceof Error ? err.message : "An error occurred"
);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
{title}
</h2>
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
disabled={isLoading}
autoFocus
className="mt-1 w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-gray-50 dark:placeholder-gray-500"
/>
</div>
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-800 dark:bg-red-950 dark:text-red-200">
{error}
</div>
)}
<div className="flex gap-2">
<Button
type="submit"
disabled={isLoading || !value.trim()}
variant={isDestructive ? "destructive" : "default"}
className="flex-1"
>
{isLoading ? "Loading..." : "Confirm"}
</Button>
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={isLoading}
className="flex-1"
>
Cancel
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useState, useRef } from "react";
import { Button } from "@/components/ui/Button";
import { useUploadFile } from "@/hooks/useFiles";
import { useToast } from "@/hooks/useToast";
export interface UploadDialogProps {
isOpen: boolean;
path: string;
onClose: () => void;
}
export function UploadDialog({ isOpen, path, onClose }: UploadDialogProps) {
const [files, setFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadMutation = useUploadFile();
const { showSuccess, showError } = useToast();
if (!isOpen) return null;
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
setFiles((prev) => [...prev, ...selectedFiles]);
};
const handleRemoveFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => {
try {
for (const file of files) {
await uploadMutation.mutateAsync({
path,
file,
});
}
showSuccess(`${files.length} file(s) uploaded successfully`);
setFiles([]);
onClose();
} catch (err) {
showError(err);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-slate-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
Upload Files
</h2>
<div className="mt-4 space-y-4">
{/* Drop Zone */}
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files);
setFiles((prev) => [...prev, ...droppedFiles]);
}}
className="rounded-md border-2 border-dashed border-gray-300 p-6 text-center transition-colors hover:border-blue-500 dark:border-slate-600 dark:hover:border-blue-400"
>
<p className="text-sm text-gray-600 dark:text-gray-400">
Drag and drop files here or{" "}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="text-blue-600 hover:underline dark:text-blue-400"
>
click to browse
</button>
</p>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
/>
</div>
{/* Files List */}
{files.length > 0 && (
<div className="space-y-2 rounded-md bg-gray-50 p-3 dark:bg-slate-900">
{files.map((file, index) => (
<div
key={`${file.name}-${index}`}
className="flex items-center justify-between text-sm"
>
<span className="flex-1 truncate text-gray-700 dark:text-gray-300">
{file.name}
</span>
<button
type="button"
onClick={() => handleRemoveFile(index)}
className="ml-2 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
</button>
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button
onClick={handleUpload}
disabled={files.length === 0 || uploadMutation.isPending}
className="flex-1"
>
{uploadMutation.isPending ? "Uploading..." : "Upload"}
</Button>
<Button
variant="secondary"
onClick={onClose}
disabled={uploadMutation.isPending}
className="flex-1"
>
Cancel
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,61 +0,0 @@
<template>
<div id="modal-background" @click="backgroundClick">
<div ref="modalContainer">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
const emit = defineEmits(["closed"]);
const modalContainer = ref(null);
onMounted(() => {
const element = document.querySelector("#focus-prompt") as HTMLElement | null;
if (element) {
element.focus();
} else if (modalContainer.value) {
(modalContainer.value as HTMLElement).focus();
}
});
const backgroundClick = (event: Event) => {
const target = event.target as HTMLElement;
if (target.id == "modal-background") {
emit("closed");
}
};
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.stopImmediatePropagation();
emit("closed");
}
});
</script>
<style scoped>
#modal-background {
position: fixed;
inset: 0;
background-color: #00000096;
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
animation: ease-in 150ms opacity-enter;
}
@keyframes opacity-enter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -1,163 +0,0 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t("prompts.copy") }}</h2>
</div>
<div class="card-content">
<p>{{ $t("prompts.copyMessage") }}</p>
<file-list
ref="fileList"
@update:selected="(val) => (dest = val)"
tabindex="1"
/>
</div>
<div
class="card-action"
style="display: flex; align-items: center; justify-content: space-between"
>
<template v-if="user.perm.create">
<button
class="button button--flat"
@click="$refs.fileList.createDir()"
:aria-label="$t('sidebar.newFolder')"
:title="$t('sidebar.newFolder')"
style="justify-self: left"
>
<span>{{ $t("sidebar.newFolder") }}</span>
</button>
</template>
<div>
<button
class="button button--flat button--grey"
@click="closeHovers"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
tabindex="3"
>
{{ $t("buttons.cancel") }}
</button>
<button
id="focus-prompt"
class="button button--flat"
@click="copy"
:aria-label="$t('buttons.copy')"
:title="$t('buttons.copy')"
tabindex="2"
>
{{ $t("buttons.copy") }}
</button>
</div>
</div>
</div>
</template>
<script>
import { mapActions, mapState, mapWritableState } from "pinia";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { useAuthStore } from "@/stores/auth";
import FileList from "./FileList.vue";
import { files as api } from "@/api";
import buttons from "@/utils/buttons";
import * as upload from "@/utils/upload";
import { removePrefix } from "@/api/utils";
export default {
name: "copy",
components: { FileList },
data: function () {
return {
current: window.location.pathname,
dest: null,
};
},
inject: ["$showError"],
computed: {
...mapState(useFileStore, ["req", "selected"]),
...mapState(useAuthStore, ["user"]),
...mapWritableState(useFileStore, ["reload", "preselect"]),
},
methods: {
...mapActions(useLayoutStore, ["showHover", "closeHovers"]),
copy: async function (event) {
event.preventDefault();
const items = [];
// Create a new promise for each file.
for (const item of this.selected) {
items.push({
from: this.req.items[item].url,
to: this.dest + encodeURIComponent(this.req.items[item].name),
name: this.req.items[item].name,
size: this.req.items[item].size,
modified: this.req.items[item].modified,
overwrite: false,
rename: this.$route.path === this.dest,
});
}
const action = async (overwrite, rename) => {
buttons.loading("copy");
await api
.copy(items, overwrite, rename)
.then(() => {
buttons.success("copy");
this.preselect = removePrefix(items[0].to);
if (this.$route.path === this.dest) {
this.reload = true;
return;
}
if (this.user.redirectAfterCopyMove)
this.$router.push({ path: this.dest });
})
.catch((e) => {
buttons.done("copy");
this.$showError(e);
});
};
const dstItems = (await api.fetch(this.dest)).items;
const conflict = upload.checkConflict(items, dstItems);
if (conflict.length > 0) {
this.showHover({
prompt: "resolve-conflict",
props: {
conflict: conflict,
},
confirm: (event, result) => {
event.preventDefault();
this.closeHovers();
for (let i = result.length - 1; i >= 0; i--) {
const item = result[i];
if (item.checked.length == 2) {
items[item.index].rename = true;
} else if (
item.checked.length == 1 &&
item.checked[0] == "origin"
) {
items[item.index].overwrite = true;
} else {
items.splice(item.index, 1);
}
}
if (items.length > 0) {
action();
}
},
});
return;
}
action(false, false);
},
},
};
</script>

View File

@@ -1,91 +0,0 @@
<template>
<div>
<div class="path-container" ref="container">
<template v-for="(item, index) in path" :key="index">
/
<span class="path-item">
<span
v-if="isDir === true || index < path.length - 1"
class="material-icons"
>folder
</span>
<span v-else class="material-icons">insert_drive_file</span>
{{ item }}
</span>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import { useRoute } from "vue-router";
import { useFileStore } from "@/stores/file";
import url from "@/utils/url";
const fileStore = useFileStore();
const route = useRoute();
const props = defineProps({
name: {
type: String,
required: true,
},
isDir: {
type: Boolean,
default: false,
},
path: {
type: String,
default: null,
},
});
const container = ref<HTMLElement | null>(null);
const path = computed(() => {
const routePath = props.path || route.path;
let basePath = fileStore.isFiles ? routePath : url.removeLastDir(routePath);
if (!basePath.endsWith("/")) {
basePath += "/";
}
basePath += props.name;
return basePath.split("/").filter(Boolean).splice(1);
});
watch(path, () => {
nextTick(() => {
const lastItem = container.value?.lastElementChild;
lastItem?.scrollIntoView({ behavior: "auto", inline: "end" });
});
});
</script>
<style scoped>
.path-container {
display: flex;
align-items: center;
margin: 0.2em 0;
gap: 0.25em;
overflow-x: auto;
max-width: 100%;
scrollbar-width: none;
opacity: 0.5;
}
.path-container::-webkit-scrollbar {
display: none;
}
.path-item {
display: flex;
align-items: center;
margin: 0.2em 0;
gap: 0.25em;
white-space: nowrap;
}
.path-item > span {
font-size: 0.9em;
}
</style>

View File

@@ -1,58 +0,0 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t("prompts.currentPassword") }}</h2>
</div>
<div class="card-content">
<p>
{{ $t("prompts.currentPasswordMessage") }}
</p>
<input
id="focus-prompt"
class="input input--block"
type="password"
@keyup.enter="submit"
v-model="password"
/>
</div>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="cancel"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.cancel") }}
</button>
<button
@click="submit"
class="button button--flat"
type="submit"
:aria-label="$t('buttons.ok')"
:title="$t('buttons.ok')"
>
{{ $t("buttons.ok") }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useLayoutStore } from "@/stores/layout";
const layoutStore = useLayoutStore();
const { currentPrompt } = layoutStore;
const password = ref("");
const submit = (event: Event) => {
currentPrompt?.confirm(event, password.value);
};
const cancel = () => {
layoutStore.closeHovers();
};
</script>

View File

@@ -1,98 +0,0 @@
<template>
<div class="card floating">
<div class="card-content">
<p v-if="!this.isListing || selectedCount === 1">
{{ $t("prompts.deleteMessageSingle") }}
</p>
<p v-else>
{{ $t("prompts.deleteMessageMultiple", { count: selectedCount }) }}
</p>
</div>
<div class="card-action">
<button
@click="closeHovers"
class="button button--flat button--grey"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
tabindex="2"
>
{{ $t("buttons.cancel") }}
</button>
<button
id="focus-prompt"
@click="submit"
class="button button--flat button--red"
:aria-label="$t('buttons.delete')"
:title="$t('buttons.delete')"
tabindex="1"
>
{{ $t("buttons.delete") }}
</button>
</div>
</div>
</template>
<script>
import { mapActions, mapState, mapWritableState } from "pinia";
import { files as api } from "@/api";
import buttons from "@/utils/buttons";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
export default {
name: "delete",
inject: ["$showError"],
computed: {
...mapState(useFileStore, [
"isListing",
"selectedCount",
"req",
"selected",
]),
...mapState(useLayoutStore, ["currentPrompt"]),
...mapWritableState(useFileStore, ["reload", "preselect"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
submit: async function () {
buttons.loading("delete");
try {
if (!this.isListing) {
await api.remove(this.$route.path);
buttons.success("delete");
this.currentPrompt?.confirm();
this.closeHovers();
return;
}
this.closeHovers();
if (this.selectedCount === 0) {
return;
}
const promises = [];
for (const index of this.selected) {
promises.push(api.remove(this.req.items[index].url));
}
await Promise.all(promises);
buttons.success("delete");
const nearbyItem =
this.req.items[Math.max(0, Math.min(this.selected) - 1)];
this.preselect = nearbyItem?.path;
this.reload = true;
} catch (e) {
buttons.done("delete");
this.$showError(e);
if (this.isListing) this.reload = true;
}
},
},
};
</script>

View File

@@ -1,40 +0,0 @@
<template>
<div class="card floating">
<div class="card-content">
<p>{{ t("prompts.deleteUser") }}</p>
</div>
<div class="card-action">
<button
id="focus-prompt"
class="button button--flat button--grey"
@click="layoutStore.closeHovers"
:aria-label="t('buttons.cancel')"
:title="t('buttons.cancel')"
tabindex="1"
>
{{ t("buttons.cancel") }}
</button>
<button
class="button button--flat"
@click="layoutStore.currentPrompt?.confirm"
tabindex="2"
>
{{ t("buttons.delete") }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useLayoutStore } from "@/stores/layout";
import { useI18n } from "vue-i18n";
const layoutStore = useLayoutStore();
const { t } = useI18n();
// const emit = defineEmits<{
// (e: "confirm"): void;
// }>();
</script>

View File

@@ -1,54 +0,0 @@
<template>
<div class="card floating">
<div class="card-content">
<p>
{{ $t("prompts.discardEditorChanges") }}
</p>
</div>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="closeHovers"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
tabindex="3"
>
{{ $t("buttons.cancel") }}
</button>
<button
class="button button--flat button--blue"
@click="currentPrompt.saveAction"
:aria-label="$t('buttons.saveChanges')"
:title="$t('buttons.saveChanges')"
tabindex="1"
>
{{ $t("buttons.saveChanges") }}
</button>
<button
id="focus-prompt"
@click="currentPrompt.confirm"
class="button button--flat button--red"
:aria-label="$t('buttons.discardChanges')"
:title="$t('buttons.discardChanges')"
tabindex="2"
>
{{ $t("buttons.discardChanges") }}
</button>
</div>
</div>
</template>
<script>
import { useLayoutStore } from "@/stores/layout";
import { mapActions, mapState } from "pinia";
export default {
name: "discardEditorChanges",
computed: {
...mapState(useLayoutStore, ["currentPrompt"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
},
};
</script>

View File

@@ -1,42 +0,0 @@
<template>
<div class="card floating" id="download">
<div class="card-title">
<h2>{{ t("prompts.download") }}</h2>
</div>
<div class="card-content">
<p>{{ t("prompts.downloadMessage") }}</p>
<button
id="focus-prompt"
v-for="(ext, format) in formats"
:key="format"
class="button button--block"
@click="layoutStore.currentPrompt?.confirm(format)"
>
{{ ext }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { useLayoutStore } from "@/stores/layout";
const layoutStore = useLayoutStore();
const { t } = useI18n();
const formats = {
zip: "zip",
tar: "tar",
targz: "tar.gz",
tarbz2: "tar.bz2",
tarxz: "tar.xz",
tarlz4: "tar.lz4",
tarsz: "tar.sz",
tarbr: "tar.br",
tarzst: "tar.zst",
};
</script>

View File

@@ -1,186 +0,0 @@
<template>
<div>
<ul class="file-list">
<li
@click="itemClick"
@touchstart="touchstart"
@dblclick="next"
role="button"
tabindex="0"
:aria-label="item.name"
:aria-selected="selected == item.url"
:key="item.name"
v-for="item in items"
:data-url="item.url"
>
{{ item.name }}
</li>
</ul>
<p>
{{ $t("prompts.currentlyNavigating") }} <code>{{ nav }}</code
>.
</p>
</div>
</template>
<script>
import { mapState, mapActions } from "pinia";
import { useAuthStore } from "@/stores/auth";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import url from "@/utils/url";
import { files } from "@/api";
import { StatusError } from "@/api/utils.js";
export default {
name: "file-list",
props: {
exclude: {
type: Array,
default: () => [],
},
},
data: function () {
return {
items: [],
touches: {
id: "",
count: 0,
},
selected: null,
current: window.location.pathname,
nextAbortController: new AbortController(),
};
},
inject: ["$showError"],
computed: {
...mapState(useAuthStore, ["user"]),
...mapState(useFileStore, ["req"]),
nav() {
return decodeURIComponent(this.current);
},
},
mounted() {
this.fillOptions(this.req);
},
unmounted() {
this.abortOngoingNext();
},
methods: {
...mapActions(useLayoutStore, ["showHover"]),
abortOngoingNext() {
this.nextAbortController.abort();
},
fillOptions(req) {
// Sets the current path and resets
// the current items.
this.current = req.url;
this.items = [];
this.$emit("update:selected", this.current);
// If the path isn't the root path,
// show a button to navigate to the previous
// directory.
if (req.url !== "/files/") {
this.items.push({
name: "..",
url: url.removeLastDir(req.url) + "/",
});
}
// If this folder is empty, finish here.
if (req.items === null) return;
// Otherwise we add every directory to the
// move options.
for (const item of req.items) {
if (!item.isDir) continue;
if (this.exclude?.includes(item.url)) continue;
this.items.push({
name: item.name,
url: item.url,
});
}
},
next: function (event) {
// Retrieves the URL of the directory the user
// just clicked in and fill the options with its
// content.
const uri = event.currentTarget.dataset.url;
this.abortOngoingNext();
this.nextAbortController = new AbortController();
files
.fetch(uri, this.nextAbortController.signal)
.then(this.fillOptions)
.catch((e) => {
if (e instanceof StatusError && e.is_canceled) {
return;
}
this.$showError(e);
});
},
touchstart(event) {
const url = event.currentTarget.dataset.url;
// In 300 milliseconds, we shall reset the count.
setTimeout(() => {
this.touches.count = 0;
}, 300);
// If the element the user is touching
// is different from the last one he touched,
// reset the count.
if (this.touches.id !== url) {
this.touches.id = url;
this.touches.count = 1;
return;
}
this.touches.count++;
// If there is more than one touch already,
// open the next screen.
if (this.touches.count > 1) {
this.next(event);
}
},
itemClick: function (event) {
if (this.user.singleClick) this.next(event);
else this.select(event);
},
select: function (event) {
// If the element is already selected, unselect it.
if (this.selected === event.currentTarget.dataset.url) {
this.selected = null;
this.$emit("update:selected", this.current);
return;
}
// Otherwise select the element.
this.selected = event.currentTarget.dataset.url;
this.$emit("update:selected", this.selected);
},
createDir: async function () {
this.showHover({
prompt: "newDir",
action: null,
confirm: (url) => {
const paths = url.split("/");
this.items.push({
name: paths[paths.length - 2],
url: url,
});
},
props: {
redirect: false,
base: this.current === this.$route.path ? null : this.current,
},
});
},
},
};
</script>

View File

@@ -1,47 +0,0 @@
<template>
<div class="card floating help">
<div class="card-title">
<h2>{{ $t("help.help") }}</h2>
</div>
<div class="card-content">
<ul>
<li><strong>F1</strong> - {{ $t("help.f1") }}</li>
<li><strong>F2</strong> - {{ $t("help.f2") }}</li>
<li><strong>DEL</strong> - {{ $t("help.del") }}</li>
<li><strong>ESC</strong> - {{ $t("help.esc") }}</li>
<li><strong>CTRL + S</strong> - {{ $t("help.ctrl.s") }}</li>
<li><strong>CTRL + SHIFT + F</strong> - {{ $t("help.ctrl.f") }}</li>
<li><strong>CTRL + Click</strong> - {{ $t("help.ctrl.click") }}</li>
<li><strong>Click</strong> - {{ $t("help.click") }}</li>
<li><strong>Double click</strong> - {{ $t("help.doubleClick") }}</li>
</ul>
</div>
<div class="card-action">
<button
id="focus-prompt"
type="submit"
@click="closeHovers"
class="button button--flat"
:aria-label="$t('buttons.ok')"
:title="$t('buttons.ok')"
tabindex="1"
>
{{ $t("buttons.ok") }}
</button>
</div>
</div>
</template>
<script>
import { mapActions } from "pinia";
import { useLayoutStore } from "@/stores/layout";
export default {
name: "help",
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
},
};
</script>

View File

@@ -1,196 +0,0 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t("prompts.fileInfo") }}</h2>
</div>
<div class="card-content">
<p v-if="selected.length > 1">
{{ $t("prompts.filesSelected", { count: selected.length }) }}
</p>
<p class="break-word" v-if="selected.length < 2">
<strong>{{ $t("prompts.displayName") }}</strong> {{ name }}
</p>
<p v-if="!dir || selected.length > 1">
<strong>{{ $t("prompts.size") }}:</strong>
<span id="content_length"></span> {{ humanSize }}
</p>
<div v-if="resolution">
<strong>{{ $t("prompts.resolution") }}:</strong>
{{ resolution.width }} x {{ resolution.height }}
</div>
<p v-if="selected.length < 2" :title="modTime">
<strong>{{ $t("prompts.lastModified") }}:</strong> {{ humanTime }}
</p>
<template v-if="dir && selected.length === 0">
<p>
<strong>{{ $t("prompts.numberFiles") }}:</strong> {{ req.numFiles }}
</p>
<p>
<strong>{{ $t("prompts.numberDirs") }}:</strong> {{ req.numDirs }}
</p>
</template>
<template v-if="!dir">
<p>
<strong>MD5: </strong
><code
><a
@click="checksum($event, 'md5')"
@keypress.enter="checksum($event, 'md5')"
tabindex="2"
>{{ $t("prompts.show") }}</a
></code
>
</p>
<p>
<strong>SHA1: </strong
><code
><a
@click="checksum($event, 'sha1')"
@keypress.enter="checksum($event, 'sha1')"
tabindex="3"
>{{ $t("prompts.show") }}</a
></code
>
</p>
<p>
<strong>SHA256: </strong
><code
><a
@click="checksum($event, 'sha256')"
@keypress.enter="checksum($event, 'sha256')"
tabindex="4"
>{{ $t("prompts.show") }}</a
></code
>
</p>
<p>
<strong>SHA512: </strong
><code
><a
@click="checksum($event, 'sha512')"
@keypress.enter="checksum($event, 'sha512')"
tabindex="5"
>{{ $t("prompts.show") }}</a
></code
>
</p>
</template>
</div>
<div class="card-action">
<button
id="focus-prompt"
type="submit"
@click="closeHovers"
class="button button--flat"
:aria-label="$t('buttons.ok')"
:title="$t('buttons.ok')"
>
{{ $t("buttons.ok") }}
</button>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from "pinia";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { filesize } from "@/utils";
import dayjs from "dayjs";
import { files as api } from "@/api";
export default {
name: "info",
inject: ["$showError"],
computed: {
...mapState(useFileStore, [
"req",
"selected",
"selectedCount",
"isListing",
]),
humanSize: function () {
if (this.selectedCount === 0 || !this.isListing) {
return filesize(this.req.size);
}
let sum = 0;
for (const selected of this.selected) {
sum += this.req.items[selected].size;
}
return filesize(sum);
},
humanTime: function () {
if (this.selectedCount === 0) {
return dayjs(this.req.modified).fromNow();
}
return dayjs(this.req.items[this.selected[0]].modified).fromNow();
},
modTime: function () {
if (this.selectedCount === 0) {
return new Date(Date.parse(this.req.modified)).toLocaleString();
}
return new Date(
Date.parse(this.req.items[this.selected[0]].modified)
).toLocaleString();
},
name: function () {
return this.selectedCount === 0
? this.req.name
: this.req.items[this.selected[0]].name;
},
dir: function () {
return (
this.selectedCount > 1 ||
(this.selectedCount === 0
? this.req.isDir
: this.req.items[this.selected[0]].isDir)
);
},
resolution: function () {
if (this.selectedCount === 1) {
const selectedItem = this.req.items[this.selected[0]];
if (selectedItem && selectedItem.type === "image") {
return selectedItem.resolution;
}
} else if (this.req && this.req.type === "image") {
return this.req.resolution;
}
return null;
},
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
checksum: async function (event, algo) {
event.preventDefault();
let link;
if (this.selectedCount) {
link = this.req.items[this.selected[0]].url;
} else {
link = this.$route.path;
}
try {
const hash = await api.checksum(link, algo);
event.target.textContent = hash;
} catch (e) {
this.$showError(e);
}
},
},
};
</script>

View File

@@ -1,164 +0,0 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t("prompts.move") }}</h2>
</div>
<div class="card-content">
<p>{{ $t("prompts.moveMessage") }}</p>
<file-list
ref="fileList"
@update:selected="(val) => (dest = val)"
:exclude="excludedFolders"
tabindex="1"
/>
</div>
<div
class="card-action"
style="display: flex; align-items: center; justify-content: space-between"
>
<template v-if="user.perm.create">
<button
class="button button--flat"
@click="$refs.fileList.createDir()"
:aria-label="$t('sidebar.newFolder')"
:title="$t('sidebar.newFolder')"
style="justify-self: left"
>
<span>{{ $t("sidebar.newFolder") }}</span>
</button>
</template>
<div>
<button
class="button button--flat button--grey"
@click="closeHovers"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
tabindex="3"
>
{{ $t("buttons.cancel") }}
</button>
<button
id="focus-prompt"
class="button button--flat"
@click="move"
:disabled="$route.path === dest"
:aria-label="$t('buttons.move')"
:title="$t('buttons.move')"
tabindex="2"
>
{{ $t("buttons.move") }}
</button>
</div>
</div>
</div>
</template>
<script>
import { mapActions, mapState, mapWritableState } from "pinia";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { useAuthStore } from "@/stores/auth";
import FileList from "./FileList.vue";
import { files as api } from "@/api";
import buttons from "@/utils/buttons";
import * as upload from "@/utils/upload";
import { removePrefix } from "@/api/utils";
export default {
name: "move",
components: { FileList },
data: function () {
return {
current: window.location.pathname,
dest: null,
};
},
inject: ["$showError"],
computed: {
...mapState(useFileStore, ["req", "selected"]),
...mapState(useAuthStore, ["user"]),
...mapWritableState(useFileStore, ["reload", "preselect"]),
excludedFolders() {
return this.selected
.filter((idx) => this.req.items[idx].isDir)
.map((idx) => this.req.items[idx].url);
},
},
methods: {
...mapActions(useLayoutStore, ["showHover", "closeHovers"]),
move: async function (event) {
event.preventDefault();
const items = [];
for (const item of this.selected) {
items.push({
from: this.req.items[item].url,
to: this.dest + encodeURIComponent(this.req.items[item].name),
name: this.req.items[item].name,
size: this.req.items[item].size,
modified: this.req.items[item].modified,
overwrite: false,
rename: false,
});
}
const action = async (overwrite, rename) => {
buttons.loading("move");
await api
.move(items, overwrite, rename)
.then(() => {
buttons.success("move");
this.preselect = removePrefix(items[0].to);
if (this.user.redirectAfterCopyMove)
this.$router.push({ path: this.dest });
else this.reload = true;
})
.catch((e) => {
buttons.done("move");
this.$showError(e);
});
};
const dstItems = (await api.fetch(this.dest)).items;
const conflict = upload.checkConflict(items, dstItems);
if (conflict.length > 0) {
this.showHover({
prompt: "resolve-conflict",
props: {
conflict: conflict,
files: items,
},
confirm: (event, result) => {
event.preventDefault();
this.closeHovers();
for (let i = result.length - 1; i >= 0; i--) {
const item = result[i];
if (item.checked.length == 2) {
items[item.index].rename = true;
} else if (
item.checked.length == 1 &&
item.checked[0] == "origin"
) {
items[item.index].overwrite = true;
} else {
items.splice(item.index, 1);
}
}
if (items.length > 0) {
action();
}
},
});
return;
}
action(false, false);
},
},
};
</script>

View File

@@ -1,105 +0,0 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ t("prompts.newDir") }}</h2>
</div>
<div class="card-content">
<p>{{ t("prompts.newDirMessage") }}</p>
<input
id="focus-prompt"
class="input input--block"
type="text"
@keyup.enter="submit"
v-model.trim="name"
tabindex="1"
/>
<CreateFilePath :name="name" :is-dir="true" :path="base" />
</div>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="layoutStore.closeHovers"
:aria-label="t('buttons.cancel')"
:title="t('buttons.cancel')"
tabindex="3"
>
{{ t("buttons.cancel") }}
</button>
<button
class="button button--flat"
:aria-label="$t('buttons.create')"
:title="t('buttons.create')"
@click="submit"
tabindex="2"
>
{{ t("buttons.create") }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref } from "vue";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { files as api } from "@/api";
import url from "@/utils/url";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import CreateFilePath from "@/components/prompts/CreateFilePath.vue";
const $showError = inject<IToastError>("$showError")!;
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
const base = computed(() => {
return layoutStore.currentPrompt?.props?.base;
});
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const name = ref<string>("");
const submit = async (event: Event) => {
event.preventDefault();
if (name.value === "") return;
// Build the path of the new directory.
let uri: string;
if (base.value) uri = base.value;
else if (fileStore.isFiles) uri = route.path + "/";
else uri = "/";
if (!fileStore.isListing) {
uri = url.removeLastDir(uri) + "/";
}
uri += encodeURIComponent(name.value) + "/";
uri = uri.replace("//", "/");
try {
await api.post(uri);
if (layoutStore.currentPrompt?.props?.redirect) {
router.push({ path: uri });
} else if (!base.value) {
const res = await api.fetch(url.removeLastDir(uri) + "/");
fileStore.updateRequest(res);
}
if (layoutStore.currentPrompt?.confirm) {
layoutStore.currentPrompt?.confirm(uri);
}
} catch (e) {
if (e instanceof Error) {
$showError(e);
}
}
layoutStore.closeHovers();
};
</script>

View File

@@ -1,87 +0,0 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ t("prompts.newFile") }}</h2>
</div>
<div class="card-content">
<p>{{ t("prompts.newFileMessage") }}</p>
<input
id="focus-prompt"
class="input input--block"
type="text"
@keyup.enter="submit"
v-model.trim="name"
/>
<CreateFilePath :name="name" />
</div>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="layoutStore.closeHovers"
:aria-label="t('buttons.cancel')"
:title="t('buttons.cancel')"
>
{{ t("buttons.cancel") }}
</button>
<button
class="button button--flat"
@click="submit"
:aria-label="t('buttons.create')"
:title="t('buttons.create')"
>
{{ t("buttons.create") }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import CreateFilePath from "@/components/prompts/CreateFilePath.vue";
import { files as api } from "@/api";
import url from "@/utils/url";
const $showError = inject<IToastError>("$showError")!;
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const name = ref<string>("");
const submit = async (event: Event) => {
event.preventDefault();
if (name.value === "") return;
// Build the path of the new directory.
let uri = fileStore.isFiles ? route.path + "/" : "/";
if (!fileStore.isListing) {
uri = url.removeLastDir(uri) + "/";
}
uri += encodeURIComponent(name.value);
uri = uri.replace("//", "/");
try {
await api.post(uri);
router.push({ path: uri });
} catch (e) {
if (e instanceof Error) {
$showError(e);
}
}
layoutStore.closeHovers();
};
</script>

View File

@@ -1,68 +0,0 @@
<template>
<base-modal v-if="modal != null" :prompt="currentPromptName" @closed="close">
<keep-alive>
<component :is="modal" />
</keep-alive>
</base-modal>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useLayoutStore } from "@/stores/layout";
import BaseModal from "./BaseModal.vue";
import Help from "./Help.vue";
import Info from "./Info.vue";
import Delete from "./Delete.vue";
import DeleteUser from "./DeleteUser.vue";
import Download from "./Download.vue";
import Rename from "./Rename.vue";
import Move from "./Move.vue";
import Copy from "./Copy.vue";
import NewFile from "./NewFile.vue";
import NewDir from "./NewDir.vue";
import Replace from "./Replace.vue";
import Share from "./Share.vue";
import ShareDelete from "./ShareDelete.vue";
import Upload from "./Upload.vue";
import DiscardEditorChanges from "./DiscardEditorChanges.vue";
import ResolveConflict from "./ResolveConflict.vue";
import CurrentPassword from "./CurrentPassword.vue";
const layoutStore = useLayoutStore();
const { currentPromptName } = storeToRefs(layoutStore);
const components = new Map<string, any>([
["info", Info],
["help", Help],
["delete", Delete],
["rename", Rename],
["move", Move],
["copy", Copy],
["newFile", NewFile],
["newDir", NewDir],
["download", Download],
["replace", Replace],
["share", Share],
["upload", Upload],
["share-delete", ShareDelete],
["deleteUser", DeleteUser],
["discardEditorChanges", DiscardEditorChanges],
["resolve-conflict", ResolveConflict],
["current-password", CurrentPassword],
]);
const modal = computed(() => {
const modal = components.get(currentPromptName.value!);
if (!modal) null;
return modal;
});
const close = () => {
if (!layoutStore.currentPrompt) return;
layoutStore.closeHovers();
};
</script>

View File

@@ -1,123 +0,0 @@
<template>
<div class="card floating">
<div class="card-title">
<h2>{{ $t("prompts.rename") }}</h2>
</div>
<div class="card-content">
<p>
{{ $t("prompts.renameMessage") }} <code>{{ oldName }}</code
>:
</p>
<input
id="focus-prompt"
class="input input--block"
type="text"
@keyup.enter="submit"
v-model.trim="name"
/>
</div>
<div class="card-action">
<button
class="button button--flat button--grey"
@click="closeHovers"
:aria-label="$t('buttons.cancel')"
:title="$t('buttons.cancel')"
>
{{ $t("buttons.cancel") }}
</button>
<button
@click="submit"
class="button button--flat"
type="submit"
:aria-label="$t('buttons.rename')"
:title="$t('buttons.rename')"
:disabled="name === '' || name === oldName"
>
{{ $t("buttons.rename") }}
</button>
</div>
</div>
</template>
<script>
import { mapActions, mapState, mapWritableState } from "pinia";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import url from "@/utils/url";
import { files as api } from "@/api";
import { removePrefix } from "@/api/utils";
export default {
name: "rename",
data: function () {
return {
name: "",
};
},
created() {
this.name = this.oldName;
},
inject: ["$showError"],
computed: {
...mapState(useFileStore, [
"req",
"selected",
"selectedCount",
"isListing",
]),
...mapWritableState(useFileStore, ["reload", "preselect"]),
oldName() {
if (!this.isListing) {
return this.req.name;
}
if (this.selectedCount === 0 || this.selectedCount > 1) {
// This shouldn't happen.
return "";
}
return this.req.items[this.selected[0]].name;
},
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
cancel: function () {
this.closeHovers();
},
submit: async function () {
if (this.name === "" || this.name === this.oldName) {
return;
}
let oldLink = "";
let newLink = "";
if (!this.isListing) {
oldLink = this.req.url;
} else {
oldLink = this.req.items[this.selected[0]].url;
}
newLink =
url.removeLastDir(oldLink) + "/" + encodeURIComponent(this.name);
try {
await api.move([{ from: oldLink, to: newLink }]);
if (!this.isListing) {
this.$router.push({ path: newLink });
return;
}
this.preselect = removePrefix(newLink);
this.reload = true;
} catch (e) {
this.$showError(e);
}
this.closeHovers();
},
},
};
</script>

Some files were not shown because too many files have changed in this diff Show More