From 411c5a089d46390c270d953822633d06196487df Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Mon, 4 May 2026 23:27:51 +0300 Subject: [PATCH] feature: implement internationalization support with locale switching and validation --- .github/copilot-instructions.md | 95 ++++- .github/scripts/check_i18n_keys.py | 151 ++++++++ .github/workflows/i18n-key-diff.yml | 24 ++ CONTRIBUTING.md | 13 + docs/i18n.md | 148 ++++++++ pom.xml | 4 + .../config/GlobalControllerAdvice.java | 8 + .../quickdrop/config/I18nConfig.java | 63 +++ .../model/ApplicationSettingsViewModel.java | 8 + .../service/NotificationService.java | 31 +- .../rostislav/quickdrop/util/FileUtils.java | 6 - src/main/resources/messages.properties | 358 ++++++++++++++++++ src/main/resources/messages_bg.properties | 335 ++++++++++++++++ src/main/resources/messages_de.properties | 335 ++++++++++++++++ src/main/resources/static/css/tailwind.css | 2 +- .../resources/static/js/locale-switcher.js | 27 ++ src/main/resources/static/js/settings.js | 7 +- .../resources/templates/admin-password.html | 30 +- .../resources/templates/app-password.html | 29 +- src/main/resources/templates/dashboard.html | 79 ++-- src/main/resources/templates/error.html | 29 +- .../resources/templates/file-history.html | 43 ++- .../resources/templates/file-password.html | 31 +- .../resources/templates/file-share-view.html | 25 +- src/main/resources/templates/fileView.html | 182 +++++---- .../templates/invalid-share-link.html | 30 +- src/main/resources/templates/listFiles.html | 121 +++--- src/main/resources/templates/pasteView.html | 67 +++- src/main/resources/templates/pastebin.html | 59 ++- src/main/resources/templates/settings.html | 278 ++++++++------ src/main/resources/templates/upload.html | 86 +++-- src/main/resources/templates/welcome.html | 33 +- 32 files changed, 2308 insertions(+), 429 deletions(-) create mode 100644 .github/scripts/check_i18n_keys.py create mode 100644 .github/workflows/i18n-key-diff.yml create mode 100644 docs/i18n.md create mode 100644 src/main/java/org/rostislav/quickdrop/config/I18nConfig.java create mode 100644 src/main/resources/messages.properties create mode 100644 src/main/resources/messages_bg.properties create mode 100644 src/main/resources/messages_de.properties create mode 100644 src/main/resources/static/js/locale-switcher.js diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e938521..0baa27e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,15 +1,82 @@ -# QuickDrop AI Working Guide (concise) +# QuickDrop — Agent Guide -- **Build/run**: Java 21. Use `./mvnw clean package` then `java -jar target/quickdrop.jar`. SQLite DB at `db/quickdrop.db`; Flyway auto-runs baseline-on-migrate. Docker image `roastslav/quickdrop:latest` exposes 8080. -- **App shape**: Spring Boot MVC + Thymeleaf. Controllers in [src/main/java/org/rostislav/quickdrop/controller](src/main/java/org/rostislav/quickdrop/controller); services in [service](src/main/java/org/rostislav/quickdrop/service); interceptors in [interceptor](src/main/java/org/rostislav/quickdrop/interceptor); entities/repos in [entity](src/main/java/org/rostislav/quickdrop/entity) and [repository](src/main/java/org/rostislav/quickdrop/repository); static assets + templates under `src/main/resources/static` and `.../templates`. -- **Settings source of truth**: Single-row settings (id=1) via [ApplicationSettingsService](src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java). Startup seeds defaults (max size 1GB, life 30d, storage `files`/`logs`, cron `0 0 2 * * *`, app password off, admin password empty, file list & admin button enabled, encryption on, default home upload) and schedules cleanup. Never create extra rows. -- **Security**: [SecurityConfig](src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java) enables whole-app password mode (`/password/login`), BCrypt auth, CSRF cookie, permissive CORS. Session timeout set in [WebConfig](src/main/java/org/rostislav/quickdrop/config/WebConfig.java). Admin and file tokens stored in-memory in [SessionService](src/main/java/org/rostislav/quickdrop/service/SessionService.java). -- **Interceptors**: [AdminPasswordSetupInterceptor](src/main/java/org/rostislav/quickdrop/interceptor/AdminPasswordSetupInterceptor.java) enforces `/admin/setup` until admin password exists. [AdminPasswordInterceptor](src/main/java/org/rostislav/quickdrop/interceptor/AdminPasswordInterceptor.java) gates `/admin/**` and file history. [FilePasswordInterceptor](src/main/java/org/rostislav/quickdrop/interceptor/FilePasswordInterceptor.java) redirects to `/file/password/{uuid}` when missing a valid file session token. -- **Upload pipeline**: `/api/file/upload-chunk` accepts chunked uploads with metadata (`description`, `keepIndefinitely`, `password`, `hidden`, `fileSize`, folder fields). [AsyncFileMergeService](src/main/java/org/rostislav/quickdrop/service/AsyncFileMergeService.java) buffers/merges chunks; [FileEncryptionService](src/main/java/org/rostislav/quickdrop/service/FileEncryptionService.java) encrypts when password + encryption enabled; final persist via [FileService](src/main/java/org/rostislav/quickdrop/service/FileService.java). Intermediate chunk calls return null—frontends must handle. -- **Storage/model**: Files stored on disk under `fileStoragePath` (from settings) with UUID filenames; metadata in `file_entity` (description, keepIndefinitely, hidden, optional password hash, encrypted flag, folder fields). [QuickdropApplication](src/main/java/org/rostislav/quickdrop/QuickdropApplication.java) ensures `./db` and storage path exist. -- **Downloads & history**: `/file/download/{uuid}` decrypts on the fly; logs uploads/renewals/downloads with IP/UA into `file_history_log` (types `UPLOAD`, `RENEWAL`, `DOWNLOAD`). Share links via `/api/file/share/{uuid}` and `/api/file/download/{uuid}/{token}` decrement/delete tokens. -- **Admin UX**: Dashboard lists files with download counts and actions (view, history, download, delete, keep indefinitely toggle, hidden toggle) plus analytics (total downloads, space, avg size). Public list and admin dashboard now paginated/searchable (page/size/query params) with in-memory caching of page results; cache evicted on uploads, deletions, renewals, hidden/keep toggles, downloads. -- **Cleanup/scheduling**: [ScheduleService](src/main/java/org/rostislav/quickdrop/service/ScheduleService.java) schedules deletions per cron/max life, missing-file cleanup, share token cleanup. App uses @EnableScheduling. -- **Thymeleaf flags**: [GlobalControllerAdvice](src/main/java/org/rostislav/quickdrop/config/GlobalControllerAdvice.java) injects template booleans (file list enabled, app password set, admin button enabled, encryption enabled). -- **Conventions**: Route all file mutations through `FileService` to ensure logging/notifications/encryption. Update interceptors when adding protected routes. Keep single settings row intact. For pagination/search, reuse Page-based service methods and propagate `page/size/query` to templates. Cache keys live in `publicFiles`, `adminFiles`, `analytics` (see [CacheConfig](src/main/java/org/rostislav/quickdrop/config/CacheConfig.java)). -- **Dev tips**: SQLite dialect configured; migrations live under `src/main/resources/db/migration`. Few/no automated tests—manual `./mvnw clean package` is typical. Logs at `log/quickdrop.log` (see properties). Default port 8080. \ No newline at end of file +Self-hosted file sharing: Spring Boot 3.5 + Thymeleaf + SQLite (Flyway) + Tailwind. Java 21. + +## Build & run + +- Build: `./mvnw clean package` (Windows: `mvnw.cmd clean package`). Output: `target/quickdrop.jar`. +- Run: `java -jar target/quickdrop.jar` → http://localhost:8080. +- Tailwind CSS: `npm run tw:build` rebuilds `src/main/resources/static/css/tailwind.css` from `tailwind-input.css`. +- No test suite — verify via manual package + smoke test. Logs at `log/quickdrop.log`, DB at `db/quickdrop.db`, uploads + under `files/` (all auto-created by [ + `QuickdropApplication`](src/main/java/org/rostislav/quickdrop/QuickdropApplication.java)). +- Docker: `roastslav/quickdrop:latest`, exposes 8080; mount `/app/db`, `/app/files`, `/app/log`. + +## Architecture (follow the layers) + +- Controllers (`controller/`): `FileViewController`, `AdminViewController`, `FileRestController` (chunked upload + share + APIs), `ShareViewController`, `PasswordViewController`, `IndexViewController`. Thymeleaf views in + `src/main/resources/templates/`. +- Services (`service/`) own all business logic. **Route every file mutation + through [`FileService`](src/main/java/org/rostislav/quickdrop/service/FileService.java)** so history logging, cache + eviction, encryption and notifications fire. +- Upload pipeline: `POST /api/file/upload-chunk` → [ + `AsyncFileMergeService`](src/main/java/org/rostislav/quickdrop/service/AsyncFileMergeService.java) buffers chunks → [ + `FileEncryptionService`](src/main/java/org/rostislav/quickdrop/service/FileEncryptionService.java) encrypts when + password + encryption flag set → `FileService` persists. Intermediate chunk calls return `null`; frontend must handle. + Metadata fields: `description`, `keepIndefinitely`, `password`, `hidden`, `fileSize`, folder fields, `isPaste`. +- Download: `/file/download/{uuid}` streams with on-the-fly decryption and logs `DOWNLOAD` into `file_history_log` ( + types: `UPLOAD`, `RENEWAL`, `DOWNLOAD`). Share links via `/api/file/share/{uuid}` issue tokens redeemed at + `/api/file/download/{uuid}/{token}` (decrement/delete). +- Storage/model: UUID filenames on disk at `fileStoragePath` (from settings); `file_entity` row holds metadata + + optional BCrypt password hash + `encrypted` flag + folder + `isPaste`. + +## Settings (single-row, id=1) + +- [`ApplicationSettingsService`](src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java) seeds + defaults on startup (max 1 GB, 30-day life, `files`/`logs` paths, cron `0 0 2 * * *`, encryption on, default home = + upload, SMTP/Discord off, etc.) and re-schedules cleanup when cron changes. **Never insert a second settings row.** +- Template-level feature flags (file list enabled, app password set, admin button, encryption on, previews, pastebin) + are injected by [`GlobalControllerAdvice`](src/main/java/org/rostislav/quickdrop/config/GlobalControllerAdvice.java) — + read from there; don't duplicate. + +## Security & interceptors + +- [`SecurityConfig`](src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java): optional whole-app password at + `/password/login`, BCrypt, CSRF cookie, permissive CORS. Session timeout configured in [ + `WebConfig`](src/main/java/org/rostislav/quickdrop/config/WebConfig.java). +- In-memory admin + file session tokens live in [ + `SessionService`](src/main/java/org/rostislav/quickdrop/service/SessionService.java) (not persisted — restarts + invalidate). +- Add new protected routes via the right interceptor: `AdminPasswordSetupInterceptor` (forces `/admin/setup` until admin + pwd exists), `AdminPasswordInterceptor` (`/admin/**` + file history), `FilePasswordInterceptor` (redirects to + `/file/password/{uuid}` when the file has a password and no valid session token). + +## Migrations & persistence + +- Flyway migrations in `src/main/resources/db/migration/V*__*.sql` (currently V1–V20). Baseline-on-migrate is enabled — + **append a new `V{n+1}__...sql`, never edit existing files.** SQLite dialect is + `org.hibernate.community.dialect.SQLiteDialect`. +- Entities in `entity/`, Spring Data repos in `repository/`. Use `@Transactional` on service methods that mutate. + +## Pagination, search & cache + +- Public list (`/files`) and admin dashboard use `page`/`size`/`query` params backed by `Page` service + methods — reuse them and pass all three params through to templates. +- Spring Cache keys are `publicFiles`, `adminFiles`, `analytics` (see [ + `CacheConfig`](src/main/java/org/rostislav/quickdrop/config/CacheConfig.java)). Any upload, deletion, renewal, + visibility/keep toggle, or download **must evict** these — copy the `@CacheEvict` pattern from `FileService`. + +## Scheduling & notifications + +- `@EnableScheduling` + [`ScheduleService`](src/main/java/org/rostislav/quickdrop/service/ScheduleService.java) handles + expiry deletion (cron from settings), missing-file row cleanup, share token expiry. Reschedule by calling its + `rescheduleCleanup(...)` when cron changes. +- [`NotificationService`](src/main/java/org/rostislav/quickdrop/service/NotificationService.java) batches email + + Discord webhook events per configured interval; call it from `FileService`, not controllers. + +## i18n + +- Bundles: `messages.properties`, `messages_bg.properties`, `messages_de.properties`. Add every new user-facing string + to all three (see `docs/i18n.md`). Resolver wired in [ + `I18nConfig`](src/main/java/org/rostislav/quickdrop/config/I18nConfig.java). \ No newline at end of file diff --git a/.github/scripts/check_i18n_keys.py b/.github/scripts/check_i18n_keys.py new file mode 100644 index 0000000..312fbe0 --- /dev/null +++ b/.github/scripts/check_i18n_keys.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Lightweight i18n key consistency check for QuickDrop. + +Compares locale bundles against messages.properties and reports: +- missing keys in locale bundles +- extra keys that do not exist in English baseline +- empty values in locale bundles + +Default mode is warn-only (exit code 0). Use --strict to fail on findings. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Dict, Iterable, List, Tuple + + +def read_text_with_fallback(path: Path) -> str: + raw = path.read_bytes() + for encoding in ("utf-8", "utf-8-sig", "cp1252", "latin-1"): + try: + return raw.decode(encoding) + except UnicodeDecodeError: + continue + # Last resort keeps the check running even on malformed files. + return raw.decode("utf-8", errors="replace") + + +def parse_properties(path: Path) -> Dict[str, str]: + data: Dict[str, str] = {} + if not path.exists(): + return data + + text = read_text_with_fallback(path) + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or line.startswith("!"): + continue + + key, value = split_property(line) + if key: + data[key] = value + + return data + + +def split_property(line: str) -> Tuple[str, str]: + # Java properties separators: '=', ':' or first whitespace. + escaped = False + for idx, ch in enumerate(line): + if escaped: + escaped = False + continue + if ch == "\\": + escaped = True + continue + if ch in ("=", ":"): + return line[:idx].strip(), line[idx + 1 :].strip() + if ch.isspace(): + return line[:idx].strip(), line[idx + 1 :].strip() + return line.strip(), "" + + +def diff_keys(base: Dict[str, str], target: Dict[str, str]) -> Tuple[List[str], List[str], List[str]]: + base_keys = set(base.keys()) + target_keys = set(target.keys()) + missing = sorted(base_keys - target_keys) + extra = sorted(target_keys - base_keys) + empty = sorted([k for k, v in target.items() if not v.strip()]) + return missing, extra, empty + + +def find_locale_files(resources_dir: Path) -> Iterable[Path]: + for path in sorted(resources_dir.glob("messages_*.properties")): + yield path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--strict", action="store_true", help="Exit non-zero when findings exist.") + parser.add_argument( + "--resources-dir", + default="src/main/resources", + help="Resources directory containing message bundles.", + ) + args = parser.parse_args() + + resources_dir = Path(args.resources_dir) + base_file = resources_dir / "messages.properties" + base = parse_properties(base_file) + + if not base: + print(f"::error::Base bundle not found or empty: {base_file}") + return 1 + + locale_files = list(find_locale_files(resources_dir)) + if not locale_files: + print("::warning::No locale bundles found (messages_*.properties).") + return 0 + + findings = 0 + summary_lines: List[str] = [] + + for locale_file in locale_files: + try: + locale_file.read_text(encoding="utf-8") + except UnicodeDecodeError: + print( + f"::warning file={locale_file}::Bundle is not UTF-8 encoded. Consider converting to UTF-8." + ) + + target = parse_properties(locale_file) + missing, extra, empty = diff_keys(base, target) + + locale_findings = len(missing) + len(extra) + len(empty) + findings += locale_findings + + summary_lines.append(f"## {locale_file.name}") + if locale_findings == 0: + summary_lines.append("- OK: key set is aligned with messages.properties") + continue + + if missing: + summary_lines.append(f"- Missing keys ({len(missing)}):") + summary_lines.extend([f" - `{k}`" for k in missing]) + if extra: + summary_lines.append(f"- Extra keys ({len(extra)}):") + summary_lines.extend([f" - `{k}`" for k in extra]) + if empty: + summary_lines.append(f"- Empty values ({len(empty)}):") + summary_lines.extend([f" - `{k}`" for k in empty]) + + for line in summary_lines: + print(line) + + if findings > 0: + print(f"::warning::i18n key check found {findings} issue(s).") + if args.strict: + print("::error::Strict mode enabled. Failing due to i18n findings.") + return 2 + else: + print("i18n key check passed with no findings.") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + + diff --git a/.github/workflows/i18n-key-diff.yml b/.github/workflows/i18n-key-diff.yml new file mode 100644 index 0000000..ab5cee7 --- /dev/null +++ b/.github/workflows/i18n-key-diff.yml @@ -0,0 +1,24 @@ +name: i18n Key Diff + +on: + pull_request: + branches: [ "dev", "master" ] + push: + branches: [ "dev", "master" ] + +jobs: + i18n-key-diff: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Check i18n key consistency (warn-only) + run: | + python .github/scripts/check_i18n_keys.py + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3ef004..cebf6c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -182,6 +182,19 @@ PR requirements: - Add screenshots for UI changes. - Add migration notes if you touched DB. +If your PR includes UI copy or localization updates: + +- Follow `docs/i18n.md` key conventions and workflow. +- Update `messages.properties` first (English source of truth). +- Add locale translations where available, or rely on English fallback by omitting missing locale keys. +- Keep i18n changes scoped by page/feature where possible. + +Run i18n key check locally before opening/updating PR: + +```powershell +python .github/scripts/check_i18n_keys.py +``` + Code style & dependencies: - Follow existing patterns in the codebase. diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..4b93231 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,148 @@ +# Internationalization (i18n) + +QuickDrop uses standard Spring Boot + Thymeleaf i18n: + +- `MessageSource` message bundles (`messages*.properties`) +- Thymeleaf message expressions (`#{...}`) +- locale switching with `?lang=` + +## Files + +- `src/main/resources/messages.properties` (English source of truth) +- `src/main/resources/messages_bg.properties` +- `src/main/resources/messages_de.properties` + +Optional helper tooling: + +- `.github/scripts/check_i18n_keys.py` (key consistency checker) + +## Conventions + +- Use dotted keys grouped by domain/page, for example: + - `nav.*` + - `button.*` + - `page.upload.*` + - `form.*` + - `validation.*` +- Keep keys semantic, not sentence-based. +- Add new keys to English first, then translate. + +## Key style guide + +Use flat dotted keys with stable prefixes. Recommended families: + +- `nav.*` +- `page.upload.*` +- `page.settings.*` +- `form.*` +- `button.*` +- `error.*` +- `notification.*` +- `validation.*` + +Rules: + +1. Reuse keys only when semantics are exactly the same. +2. Avoid generic names like `label1` or `textA`. +3. Prefer placeholders over concatenation. +4. Do not include HTML markup in message values. +5. Keep English as canonical source. + +Examples: + +- Good: `page.upload.title`, `button.save`, `validation.email.invalid` +- Bad: `uploadTitleText`, `message_12`, `saveTextForBlueButton` + +## Translator notes + +- Add translator notes as comments directly above keys in `messages.properties`. +- Use notes when placeholders or split strings are involved. +- Example note style: + - `# Translator note: {0} is the app name.` + - `# Translator note: split around numeric day count in template.` + +## Fallback behavior + +- Default locale is English. +- If a key is missing in a locale file, Spring falls back to `messages.properties`. +- QuickDrop does not rely on the server system locale. +- For unfinished translations, prefer omitting a key (fallback) over leaving an empty value. + +## Adding keys + +1. Add the key to `messages.properties` first. +2. Add translated values in each `messages_.properties` file you can validate. +3. If a translation is not ready, omit the locale key (English fallback applies). +4. Keep keys in the proper prefix family (`nav.*`, `page.*`, `button.*`, etc.). + +## Adding a new locale file + +1. Create `src/main/resources/messages_.properties` (for example `messages_fr.properties`). +2. Start by copying keys from `messages.properties`. +3. Translate values and keep key names unchanged. +4. Verify UI manually with `?lang=` and run the key check script. + +## QA guardrails + +QuickDrop includes a lightweight i18n CI check: + +- Workflow: `.github/workflows/i18n-key-diff.yml` +- Script: `.github/scripts/check_i18n_keys.py` +- Current mode: warn-only (non-blocking) + +The script reports: + +- keys missing in locale bundles +- extra keys not present in English baseline +- empty values in locale bundles + +Run locally: + +```powershell +python .github/scripts/check_i18n_keys.py +``` + +Optional strict mode (for maintainers/future enforcement): + +```powershell +python .github/scripts/check_i18n_keys.py --strict +``` + +## How to test locally + +Open the same page with different query parameters: + +- `?lang=en` +- `?lang=bg` +- `?lang=de` + +Example: + +- `/file/upload?lang=de` + +## Contributor workflow + +1. Add/adjust key in `messages.properties` first. +2. Add translations for the same key in locale files. +3. Keep changes scoped to one page/feature when possible. +4. In PR description, include screenshots for changed text-heavy pages. +5. If translation is uncertain, leave the locale key out (English fallback is preferred over incorrect copy). +6. Follow key prefixes from this guide so contributors can find keys quickly. +7. If you rename or delete keys, include migration notes in the PR. +8. Keep i18n PRs focused by page/feature to simplify review. + +## Suggested review checklist + +- Keys follow approved prefixes and naming style. +- English text is clear and context-accurate. +- Placeholder usage is correct (`{0}`, `{1}`, ...). +- No HTML is embedded in message values. +- Locale files are updated or fallback behavior is intentional. + +## Notes on key ordering + +- There is currently no auto-sort tool rewriting bundle files. +- Keep related keys grouped by section; avoid large unrelated reorder-only diffs. + + + diff --git a/pom.xml b/pom.xml index 2926d5d..3a535dc 100644 --- a/pom.xml +++ b/pom.xml @@ -53,6 +53,10 @@ org.springframework.boot spring-boot-starter-mail + + org.springframework.boot + spring-boot-starter-validation + org.springframework.boot spring-boot-starter-cache diff --git a/src/main/java/org/rostislav/quickdrop/config/GlobalControllerAdvice.java b/src/main/java/org/rostislav/quickdrop/config/GlobalControllerAdvice.java index a24ea5a..1cf2c0b 100644 --- a/src/main/java/org/rostislav/quickdrop/config/GlobalControllerAdvice.java +++ b/src/main/java/org/rostislav/quickdrop/config/GlobalControllerAdvice.java @@ -3,10 +3,13 @@ package org.rostislav.quickdrop.config; import jakarta.servlet.http.HttpServletRequest; import org.rostislav.quickdrop.service.ApplicationSettingsService; import org.rostislav.quickdrop.service.SessionService; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ModelAttribute; +import java.util.Locale; + @ControllerAdvice public class GlobalControllerAdvice { @@ -43,5 +46,10 @@ public class GlobalControllerAdvice { model.addAttribute("canUseKeepIndefinitely", !keepIndefinitelyAdminOnly || hasAdminSession); model.addAttribute("isHideFromListAdminOnly", hideFromListAdminOnly); model.addAttribute("canHideFromList", !hideFromListAdminOnly || hasAdminSession); + Locale activeLocale = LocaleContextHolder.getLocale(); + String currentLang = activeLocale == null || activeLocale.getLanguage() == null || activeLocale.getLanguage().isBlank() + ? "en" + : activeLocale.getLanguage(); + model.addAttribute("currentLang", currentLang); } } diff --git a/src/main/java/org/rostislav/quickdrop/config/I18nConfig.java b/src/main/java/org/rostislav/quickdrop/config/I18nConfig.java new file mode 100644 index 0000000..101192e --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/config/I18nConfig.java @@ -0,0 +1,63 @@ +package org.rostislav.quickdrop.config; + +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; +import org.springframework.validation.Validator; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; +import org.springframework.web.servlet.i18n.SessionLocaleResolver; + +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +@Configuration +public class I18nConfig implements WebMvcConfigurer { + + @Bean + public MessageSource messageSource() { + ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); + messageSource.setBasename("classpath:messages"); + messageSource.setDefaultEncoding(StandardCharsets.UTF_8.name()); + messageSource.setFallbackToSystemLocale(false); + messageSource.setUseCodeAsDefaultMessage(true); + return messageSource; + } + + @Bean + public LocaleResolver localeResolver() { + SessionLocaleResolver localeResolver = new SessionLocaleResolver(); + localeResolver.setDefaultLocale(Locale.ENGLISH); + return localeResolver; + } + + @Bean + public LocaleChangeInterceptor localeChangeInterceptor() { + LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor(); + interceptor.setParamName("lang"); + return interceptor; + } + + @Bean + public LocalValidatorFactoryBean validator(MessageSource messageSource) { + LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean(); + factoryBean.setValidationMessageSource(messageSource); + return factoryBean; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(localeChangeInterceptor()); + } + + @Override + public Validator getValidator() { + return validator(messageSource()); + } +} + + diff --git a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java index 00edd6f..74ef012 100644 --- a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java +++ b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java @@ -1,17 +1,24 @@ package org.rostislav.quickdrop.model; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; public class ApplicationSettingsViewModel { private Long id; + @Min(value = 1, message = "{validation.number.positive}") private long maxFileSize; + @Min(value = 1, message = "{validation.number.positive}") private long maxFileLifeTime; + @NotBlank(message = "{validation.required}") private String fileStoragePath; private String logStoragePath; + @NotBlank(message = "{validation.required}") private String fileDeletionCron; private boolean appPasswordEnabled; private String appPassword; + @Min(value = 0, message = "{validation.number.nonNegative}") private long sessionLifeTime; private boolean isFileListPageEnabled; private boolean isAdminDashboardButtonEnabled; @@ -19,6 +26,7 @@ public class ApplicationSettingsViewModel { private boolean disableUploadPassword; private boolean disablePreview; private boolean metadataStrippingEnabled; + @Min(value = 1, message = "{validation.number.positive}") private long maxPreviewSizeBytes; private String defaultHomePage; private boolean keepIndefinitelyAdminOnly; diff --git a/src/main/java/org/rostislav/quickdrop/service/NotificationService.java b/src/main/java/org/rostislav/quickdrop/service/NotificationService.java index 4dd3a4e..17f367b 100644 --- a/src/main/java/org/rostislav/quickdrop/service/NotificationService.java +++ b/src/main/java/org/rostislav/quickdrop/service/NotificationService.java @@ -15,12 +15,16 @@ import java.util.concurrent.*; import static org.rostislav.quickdrop.util.DataValidator.safeString; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; + @Service public class NotificationService { private static final Logger logger = LoggerFactory.getLogger(NotificationService.class); private static final long DEFAULT_FLUSH_POLL_SECONDS = 60; private static final RestTemplate REST_TEMPLATE = new RestTemplate(); private final ApplicationSettingsService applicationSettingsService; + private final MessageSource messageSource; private final Queue pendingMessages = new ConcurrentLinkedQueue<>(); private final Object schedulerLock = new Object(); private final ExecutorService notificationExecutor = Executors.newSingleThreadExecutor(r -> { @@ -34,14 +38,13 @@ public class NotificationService { private volatile long lastFlushEpochMillis = System.currentTimeMillis(); private ScheduledExecutorService scheduler; - - public NotificationService(ApplicationSettingsService applicationSettingsService) { + public NotificationService(ApplicationSettingsService applicationSettingsService, MessageSource messageSource) { this.applicationSettingsService = applicationSettingsService; - startSchedulerIfNeeded(shouldSendDiscord(), shouldSendEmail()); + this.messageSource = messageSource; } - public void notifyFileAction(FileEntity fileEntity, FileHistoryType type) { - if (fileEntity == null) { + public void notifyFileAction(FileEntity file, FileHistoryType type) { + if (file == null) { return; } @@ -59,10 +62,10 @@ public class NotificationService { case DELETION -> "deleted"; }; - String summary = "File '" + fileEntity.name + "' (" + fileEntity.uuid + ") was " + event + "."; + String summary = "File '" + file.name + "' (" + file.uuid + ") was " + event + "."; StringBuilder detailsBuilder = new StringBuilder(); if (type == FileHistoryType.UPLOAD) { - detailsBuilder.append("Size: ").append(fileEntity.size).append(" bytes"); + detailsBuilder.append("Size: ").append(file.size).append(" bytes"); } String details = detailsBuilder.toString(); String formattedMessage = details.isBlank() ? summary : summary + "\n---\n" + details; @@ -106,15 +109,16 @@ public class NotificationService { public NotificationTestResult sendTestDiscord() { String url = safeString(applicationSettingsService.getDiscordWebhookUrl()); if (url.isBlank()) { - return NotificationTestResult.failure("Discord webhook URL is not configured."); + return NotificationTestResult.failure(messageSource.getMessage("page.settings.notifications.discord.missingUrl", null, "Discord webhook URL is not configured.", LocaleContextHolder.getLocale())); } try { REST_TEMPLATE.postForEntity(url, Map.of("content", "QuickDrop notification test (Discord)"), Void.class); - return NotificationTestResult.success("Discord test notification sent."); + return NotificationTestResult.success(messageSource.getMessage("page.settings.notifications.discord.success", null, "Discord test notification sent.", LocaleContextHolder.getLocale())); } catch (Exception e) { logger.warn("Discord test notification failed: {}", e.getMessage()); - return NotificationTestResult.failure("Discord test failed: " + summarizeReason(e.getMessage())); + String errorMsg = messageSource.getMessage("page.settings.notifications.discord.failed", new Object[]{summarizeReason(e.getMessage())}, "Discord test failed: " + summarizeReason(e.getMessage()), LocaleContextHolder.getLocale()); + return NotificationTestResult.failure(errorMsg); } } @@ -146,7 +150,7 @@ public class NotificationService { String from = safeString(applicationSettingsService.getEmailFrom()); String[] recipients = parseRecipients(); if (mailSender == null || from.isBlank() || recipients.length == 0) { - return NotificationTestResult.failure("Email settings incomplete (host/from/recipients)."); + return NotificationTestResult.failure(messageSource.getMessage("page.settings.notifications.email.incomplete", null, "Email settings incomplete (host/from/recipients).", LocaleContextHolder.getLocale())); } MimeMessage message = mailSender.createMimeMessage(); @@ -157,10 +161,11 @@ public class NotificationService { helper.setText("This is a QuickDrop notification test email. If you see this, SMTP settings are working."); mailSender.send(message); - return NotificationTestResult.success("Email test notification sent."); + return NotificationTestResult.success(messageSource.getMessage("page.settings.notifications.email.success", null, "Email test notification sent.", LocaleContextHolder.getLocale())); } catch (Exception e) { logger.warn("Email test notification failed: {}", e.getMessage()); - return NotificationTestResult.failure("Email test failed: " + summarizeReason(e.getMessage())); + String errorMsg = messageSource.getMessage("page.settings.notifications.email.failed", new Object[]{summarizeReason(e.getMessage())}, "Email test failed: " + summarizeReason(e.getMessage()), LocaleContextHolder.getLocale()); + return NotificationTestResult.failure(errorMsg); } } diff --git a/src/main/java/org/rostislav/quickdrop/util/FileUtils.java b/src/main/java/org/rostislav/quickdrop/util/FileUtils.java index a60fc2e..4e102aa 100644 --- a/src/main/java/org/rostislav/quickdrop/util/FileUtils.java +++ b/src/main/java/org/rostislav/quickdrop/util/FileUtils.java @@ -157,7 +157,6 @@ public class FileUtils { public static String guessContentType(String fileName, boolean isImage, boolean isText, boolean isPdf) { if (isImage) { - if (fileName.toLowerCase().endsWith(".svg")) return "image/svg+xml"; if (fileName.toLowerCase().endsWith(".webp")) return "image/webp"; if (fileName.toLowerCase().endsWith(".gif")) return "image/gif"; if (fileName.toLowerCase().endsWith(".png")) return "image/png"; @@ -167,11 +166,6 @@ public class FileUtils { return "application/pdf"; } if (isText) { - if (fileName.toLowerCase().endsWith(".json")) return "application/json"; - if (fileName.toLowerCase().endsWith(".xml")) return "application/xml"; - if (fileName.toLowerCase().endsWith(".csv")) return "text/csv"; - if (fileName.toLowerCase().endsWith(".tsv")) return "text/tab-separated-values"; - if (fileName.toLowerCase().endsWith(".md")) return "text/markdown"; return "text/plain; charset=UTF-8"; } return "application/octet-stream"; diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties new file mode 100644 index 0000000..49dfc19 --- /dev/null +++ b/src/main/resources/messages.properties @@ -0,0 +1,358 @@ +# ========================================================== +# QuickDrop i18n catalog (English source of truth) +# +# Key style: +# - Use flat dotted keys. +# - Prefix by domain/page: nav.*, page.upload.*, page.settings.*, form.* +# - Reuse common keys only when meaning is identical. +# - Do not embed HTML in values. +# ========================================================== +# ------------------------------ +# Common reusable labels +# ------------------------------ +button.cancel=Cancel +button.upload=Upload +button.delete=Delete +button.edit=Edit +button.search=Search +button.selectFile=Select file +button.selectFolder=Select folder +button.copy=Copy +button.confirm=Confirm +form.description.placeholder=Description +form.password.optional=Password (Optional) +# ------------------------------ +# Navigation +# ------------------------------ +nav.viewFiles=View Files +nav.pastebin=Pastebin +nav.adminDashboard=Admin Dashboard +nav.logout=Logout +nav.poweredBy=Powered by QuickDrop +nav.toggleNavigation=Toggle navigation +# ------------------------------ +# Page: Upload +# ------------------------------ +# Translator note: {0} is the configured app name. +page.upload.title.browser={0} - Upload +page.upload.title=Upload a File or Folder +page.upload.acceptedTypes=Accepted file types: any +page.upload.maxSizeLabel=Max size +page.upload.dropzone.title=Drop file or folder +page.upload.encryptionNotice=All password-protected files are also encrypted for additional security. +page.upload.metadataNotice=Metadata stripping is enabled. Supported: +page.upload.validation.fileExceedsLimit=File exceeds the {0} limit. +page.upload.validation.folderExceedsLimit=Folder exceeds the {0} limit. +page.upload.processingFolder=Processing folder... +page.upload.folderSelected=Folder selected: {0} ({1} items) +page.upload.noCandidate=No upload candidate is available. +form.keepIndefinitely=Keep indefinitely +# Translator note: split around numeric max days value in template. +form.keepIndefinitely.help.beforeDays=If checked, this file will not be auto-deleted after +form.keepIndefinitely.help.afterDays=days. +form.hideFromList=Hide from file list +form.hideFromList.help=If checked, this file will not appear on the View Files page. +upload.warning.privacyNotice=All known metadata will be removed, but this format may still contain details that give information about your identity. +upload.warning.technicalInfo=Technical info +# ------------------------------ +# Page: Settings (reserved for migration) +# ------------------------------ +# ------------------------------ +# Errors +# ------------------------------ +# ------------------------------ +# Notifications +# ------------------------------ +notification.upload.failed=Upload failed. Please try again. +notification.upload.noFileInfo=Upload finished but no file information was returned from the server. +notification.selection.required=Select a file or folder to upload first. +notification.folder.processingFailed=Unable to prepare folder for upload. +notification.copied=Copied +notification.failed=Failed +# ------------------------------ +# Validation (shared) +# ------------------------------ +validation.required=This field is required. +validation.number.positive=Enter a value greater than 0. +# ------------------------------ +# Phase 3 incremental migration keys +# ------------------------------ +nav.uploadFile=Upload File +nav.settings=Settings +nav.dashboard=Dashboard +button.submit=Submit +button.view=View +button.history=History +button.download=Download +form.password.label=Password +form.password.placeholder=Password +form.password.file.placeholder=Enter the file password... +form.adminPassword.label=Admin Password +form.adminPassword.placeholder=Admin Password +page.settings.title.browser={0} Admin Settings +page.settings.adminTitle=Admin Settings +page.password.title.browser={0} - Password +page.password.enterFile=Enter Password +page.password.enterAppPrompt=Enter the app password to continue. +page.password.protectedLabel=Protected +page.adminPassword.title.browser={0} - Admin Login +page.adminPassword.title=Admin Password Required +page.files.title.browser={0} - Files +page.files.title=All files +page.files.search.label=Search files +page.files.search.placeholder=Search files by name or date... +page.files.empty.title=No files have been uploaded yet. +page.files.empty.startBy=Start by +page.files.empty.uploadLink=uploading a file +page.files.goToFilePage=Go to file page +pagination.previous=Previous +pagination.next=Next +pagination.perPage=Per page +pagination.page=Page +pagination.of=of +page.fileView.title.browser={0} - File View +page.fileView.title=File View +page.dashboard.title.browser={0} Admin +page.dashboard.title=Admin Dashboard +page.dashboard.analytics=Analytics +page.dashboard.files=Files +page.dashboard.totalDownloads=Total Downloads +page.dashboard.excludingDeleted=Excluding deleted files +page.dashboard.totalSpaceUsed=Total Space Used +page.dashboard.averageFileSize=Average File Size +page.dashboard.search.label=Search files +page.dashboard.search.placeholder=Search by name, description, or UUID +table.name=Name +table.uploadDateLastRenewed=Upload Date/Last Renewed +table.size=Size +table.downloads=Downloads +page.error.title.browser={0} - Error +page.error.title=Oops! +page.error.message=Something went wrong. Please try again later. +page.error.goHome=Go Back to Main Page +# ------------------------------ +# Phase 5 validation + backend messages +# ------------------------------ +validation.number.nonNegative=Enter a value greater than or equal to 0. +validation.cron.invalid=Invalid cron expression. +validation.cron.invalid.example=Invalid cron expression. Please enter a valid cron pattern (e.g., 0 0 2 * * * for 2:00 AM daily). +validation.cron.nextRun.none=No upcoming run. +validation.cron.unableToParse=Unable to parse cron. +notification.settings.saved=Settings saved. +notification.target.unknown=Unknown notification target. +# ------------------------------ +# Remaining page migration keys +# ------------------------------ +nav.language=Language +page.welcome.title.browser=Welcome to {0} +page.welcome.title=Welcome to {0} +page.welcome.setupThanks=Thank you for setting up {0}! +page.welcome.setupPrompt=Please set an admin password for the dashboard. +page.welcome.passwordMismatch=Passwords do not match. +page.welcome.setAdminPassword=Set Admin Password +form.password.confirm=Confirm Password +form.password.confirm.placeholder=Confirm Password +page.history.title.browser={0} Admin - History +page.history.title=History +page.history.downloadHistory=Download History +page.history.fileDetails=File Details +page.history.size=Size +page.history.uploadDate=Upload Date +page.history.totalDownloads=Total Downloads +page.history.date=Date +page.history.action=Action +page.history.ipAddress=IP Address +page.history.userAgent=User Agent +page.history.noDescription=No description provided +page.share.title.browser={0} - Shared File +page.share.title=Shared File +page.share.uploaded=Uploaded +page.share.fileSize=File Size +page.invalidShare.title.browser={0} - Invalid Link +page.invalidShare.title=Link Expired +page.invalidShare.message=This share link is no longer valid. The file you are trying to access has expired or the link has been used. +page.invalidShare.backHome=Return to Homepage +page.paste.title.browser={0} - Pastebin +page.paste.newTitle=New Paste +page.paste.editTitle=Edit Paste +page.paste.field.title=Title +page.paste.field.mode=Mode +page.paste.mode.markdown=Markdown (.md) +page.paste.mode.text=Plain Text (.txt) +page.paste.lineNumbers=Line numbers +page.paste.wordWrap=Word wrap +page.paste.fontSize=Font size +page.paste.preview=Preview +page.paste.content.placeholder=Write your text here... +page.paste.password.label=Password (optional) +page.paste.password.placeholder=Set password to protect this paste +page.paste.password.help=Leaving this empty removes password protection for the saved paste. +page.paste.save=Save Paste +page.paste.saveChanges=Save Changes +page.pasteView.title.browser={0} - Paste View +page.pasteView.untitled=Untitled paste +page.pasteView.copyText=Copy text +page.pasteView.raw=Raw +page.pasteView.markdown=Markdown +page.pasteView.renderedHint=Rendered markdown is enabled for this paste. +page.pasteView.share=Share +page.pasteView.generateLink=Generate link +page.pasteView.copyShareLink=Copy share link +page.pasteView.generateLinkFailed=Unable to generate share link +page.fileView.keepIndefinitely=Keep indefinitely: +page.fileView.hideFromList=Hide file from list: +page.fileView.fileSize=File Size: +page.fileView.folderContents=Folder contents +page.fileView.editPaste=Edit Paste +page.fileView.preview=Preview +page.fileView.share=Share +page.fileView.shareBypassNotice=Share links always bypass file and app passwords. +page.fileView.daysValid=Days valid +page.fileView.allowedDownloads=Allowed downloads +page.fileView.autoShareLink=Automatic share link +page.fileView.uploadedAt=Uploaded at: +page.fileView.uploadedRenewedAt=Uploaded/Renewed at: +page.fileView.retention.beforeDays=Files are kept only for +page.fileView.retention.afterDays=days after this date. +page.fileView.preparingDownload=Your file is being prepared for download. Please wait... +page.fileView.renewLifetime=Renew File Lifetime +page.fileView.deleteFile=Delete File +page.fileView.loadPreview=Load preview ({0} MB limit) +page.fileView.preview.clickToLoad=Click Load preview to fetch. +page.fileView.preview.loading=Loading preview... +page.fileView.shareAdvancedHint=Add an expiration date and/or download limit, or choose unlimited to keep the link open-ended. +page.fileView.shareSimplifiedHint=Simplified share links are enabled: a non-expiring link with unlimited downloads is generated automatically. +page.fileView.noExpiration=No expiration (unlimited time) +page.fileView.unlimitedDownloads=Unlimited downloads +page.fileView.sharePlaceholder=Generate a share link that bypasses passwords +page.fileView.shareDisabled=Share links are disabled by the administrator. +page.fileView.daysValidNonNegative=Days valid cannot be negative. +page.fileView.allowedDownloadsNonNegative=Allowed downloads cannot be negative. +page.fileView.shareGenerateFailed=Failed to generate share link. +page.fileView.preview.unavailable=Preview unavailable. +page.fileView.preview.imageAlt=Preview +page.fileView.preview.openInNewTab=Open {0} in a new tab +page.fileView.preview.jsonFormatted=Formatted +page.fileView.preview.jsonTree=Tree +page.fileView.preview.csvNoRows=No rows to display. +page.fileView.preview.csvShowingFirst=Showing first {0} rows out of {1}. +page.fileView.preview.codeTruncated=... (truncated) +page.fileView.folderFallbackName=folder +page.fileView.folderNoManifest=No manifest available. +page.fileView.folderRenderFailed=Unable to render folder contents. +page.dashboard.keepIndefinitely=Keep indefinitely +page.dashboard.hidden=Hidden +page.dashboard.noFilesFound=No files found. +page.dashboard.deleteConfirm=Are you sure you want to delete this file? This action cannot be undone. +page.settings.branding.title=Branding +page.settings.branding.appName=Application name +page.settings.branding.appName.placeholder=My Brand +page.settings.branding.appName.help=Shown in the navbar, page titles, and share views. Leave empty to reset to QuickDrop. +page.settings.branding.logo=Logo / favicon +page.settings.branding.logo.browse=Browse +page.settings.branding.logo.reset=Reset to default logo +page.settings.branding.logo.resetPending=Default logo will be restored on save. +page.settings.branding.logo.help=Upload a square image for best results. Leave empty to keep the current logo or reset to restore the QuickDrop icon. +page.settings.file.title=File Settings +page.settings.file.maxSizeMb=Max File Size (MB) +page.settings.file.maxLifetimeDays=Max File Lifetime (days) +page.settings.file.storagePath=File Storage Path +page.settings.file.disablePreview=Disable file previews +page.settings.file.disablePreview.help=Hide inline previews for images/text on the file page. +page.settings.file.maxPreviewMb=Max preview size (MB) +page.settings.file.maxPreviewMb.help=Above this size users must click "Load preview". +page.settings.file.keepIndefinitely.title=Keep indefinitely +page.settings.file.keepIndefinitely.adminOnly=Require admin session for "Keep indefinitely" +page.settings.file.keepIndefinitely.adminOnly.help=When enabled, only admins with an active session can mark uploads to keep indefinitely. +page.settings.file.hideFromList.title=Hide from list +page.settings.file.hideFromList.adminOnly=Require admin session to hide files +page.settings.file.hideFromList.adminOnly.help=When enabled, only admins can set or change "Hide from file list". +page.settings.system.title=System Settings +page.settings.system.sessionLifetimeMinutes=Session Lifetime (minutes) +page.settings.system.sessionLifetimeMinutes.help=This impacts how long file and admin sessions are kept. +page.settings.system.defaultHomePage=Default Home Page +page.settings.system.defaultHomePage.upload=Upload Page +page.settings.system.defaultHomePage.list=File List Page +page.settings.system.defaultHomePage.paste=Pastebin Page +page.settings.system.fileDeletionCron=File Deletion Cron Expression +page.settings.system.nextRun=Next run +page.settings.system.fileList=File List +page.settings.system.fileList.enabled=Enable File List Page +page.settings.system.adminDashboard=Admin Dashboard +page.settings.system.adminDashboard.showButton=Show Admin Dashboard Button +page.settings.system.adminDashboard.directUrl=Still available at /admin/dashboard +page.settings.system.pastebin=Pastebin +page.settings.system.pastebin.enabled=Enable Pastebin Page +page.settings.system.pastebin.help=Adds a text editor tab and allows selecting Pastebin as default home page. +page.settings.notifications.title=Notification Settings +page.settings.notifications.discord.title=Discord Webhook +page.settings.notifications.discord.help=Post file activity to a Discord channel. +page.settings.notifications.discord.webhookUrl=Webhook URL +page.settings.notifications.discord.webhookUrl.placeholder=https://discord.com/api/webhooks/... +page.settings.notifications.discord.testButton=Send Test to Discord +page.settings.notifications.email.title=Email Notifications +page.settings.notifications.email.help=Send emails on upload, download, and renewal events. +page.settings.notifications.email.fromAddress=From Address +page.settings.notifications.email.fromAddress.placeholder=noreply@example.com +page.settings.notifications.email.recipients=Recipients (comma separated) +page.settings.notifications.email.recipients.placeholder=admin@example.com, alerts@example.com +page.settings.notifications.email.smtpHost=SMTP Host +page.settings.notifications.email.smtpHost.placeholder=smtp.example.com +page.settings.notifications.email.smtpPort=SMTP Port +page.settings.notifications.email.smtpUsername=SMTP Username +page.settings.notifications.email.smtpPassword=SMTP Password +page.settings.notifications.email.useStartTls=Use STARTTLS +page.settings.notifications.email.useImplicitSsl=Use Implicit SSL (SMTPS) +page.settings.notifications.email.tlsHelp=Use STARTTLS with port 587. Use Implicit SSL with port 465. +page.settings.notifications.email.testButton=Send Test Email +page.settings.notifications.batch.title=Batch notifications +page.settings.notifications.batch.help=Pool events and send every N minutes. Requires at least one channel enabled. +page.settings.notifications.batch.interval=Batch interval (minutes) +page.settings.notifications.testInProgress=Testing... +page.settings.notifications.requestFailed=Request failed. See logs. +page.settings.security.title=Security Settings +page.settings.security.appPasswordEnabled=Enable Password Protection +page.settings.security.appPasswordEnabled.help=Protect the whole app with a password +page.settings.security.appPassword=App Password +page.settings.security.disableUploadPasswords=Disable upload passwords +page.settings.security.disableUploadPasswords.help=Prevents users from setting passwords on uploads (and disables per-file encryption). +page.settings.security.disableShareLinks=Disable share links +page.settings.security.disableShareLinks.help=Hide share options on file pages and block new share links from being generated. +page.settings.security.simplifyShareLinks=Simplify share links +page.settings.security.simplifyShareLinks.help=Auto-generate an unlimited, non-expiring share link and hide manual share options on file pages. +page.settings.security.disableFileEncryption=Disable File Encryption +page.settings.security.disableFileEncryption.help=If checked, files will not be encrypted even if file password protection is enabled. +page.settings.security.metadataStripping=Metadata stripping +page.settings.security.metadataStripping.help=Strip optional file metadata where possible. +page.settings.save=Save Settings +page.settings.about.title=About {0} +page.settings.about.version=Version +page.settings.about.database=Database +page.settings.about.database.sqlite=SQLite +page.settings.about.javaVersion=Java Version +page.settings.about.osInfo=OS Info +page.settings.about.unknown=Unknown +page.settings.about.notAvailable=N/A +validation.settings.maxFileSize=Enter a max file size (MB) greater than 0. +validation.settings.maxFileLifetime=Enter a max file lifetime (days) greater than 0. +validation.settings.fileStoragePathRequired=File storage path is required. +validation.settings.cronRequired=Cron expression is required. +validation.settings.sessionLifetime=Enter a session lifetime in minutes (positive number). +validation.settings.previewSize=Enter preview size (MB) greater than 0. +validation.settings.defaultHomePage=Choose upload, list, or paste. +validation.settings.appPasswordRequired=App password is required when protection is enabled. +validation.settings.discordWebhook=Enter a valid Discord webhook URL. +validation.settings.emailFrom=Enter a valid From email. +validation.settings.emailRecipients=Enter at least one recipient. +validation.settings.smtpHost=SMTP host is required. +validation.settings.smtpPort=Enter a valid SMTP port. +validation.settings.enableChannelBeforeBatch=Enable Discord or Email before batching. +validation.settings.batchInterval=Enter a batch interval in minutes. +validation.settings.failed=Validation failed +page.settings.notifications.discord.missingUrl=Discord webhook URL is not configured. +page.settings.notifications.discord.success=Discord test notification sent. +page.settings.notifications.discord.failed=Discord test failed: {0} +page.settings.notifications.email.incomplete=Email settings incomplete (host/from/recipients). +page.settings.notifications.email.success=Email test notification sent. +page.settings.notifications.email.failed=Email test failed: {0} +page.settings.notifications.testFailed=Request failed. See logs. diff --git a/src/main/resources/messages_bg.properties b/src/main/resources/messages_bg.properties new file mode 100644 index 0000000..e807447 --- /dev/null +++ b/src/main/resources/messages_bg.properties @@ -0,0 +1,335 @@ +# Common reusable labels +button.cancel=Отказ +button.upload=Качи +button.delete=Изтрий +button.edit=Редактирай +button.search=Търси +button.selectFile=Избери файл +button.selectFolder=Избери папка +button.copy=Копирай +button.confirm=Потвърди +form.description.placeholder=Описание +form.password.optional=Парола (по избор) +# Navigation +nav.viewFiles=Преглед на файлове +nav.pastebin=Pastebin +nav.adminDashboard=Админ табло +nav.logout=Изход +nav.poweredBy=Powered by QuickDrop +nav.uploadFile=Качи файл +nav.settings=Настройки +nav.dashboard=Табло +nav.toggleNavigation=Превключване на навигацията +# Page: Upload +page.upload.title.browser={0} - Качване +page.upload.title=Качване на файл или папка +page.upload.acceptedTypes=Приети типове файлове: всички +page.upload.maxSizeLabel=Макс. размер +page.upload.dropzone.title=Пуснете файл или папка +page.upload.encryptionNotice=Всички файлове с парола също се криптират за допълнителна сигурност. +page.upload.metadataNotice=Премахването на метаданни е включено. Поддържани: +page.upload.retention.standard=Файловете се изтриват след {0} дни, освен ако не е избрано „Запази безсрочно“. +page.upload.retention.adminOnly=Файловете се изтриват след {0} дни. +page.upload.status.started=Качването започна... +page.upload.validation.fileExceedsLimit=Файлът надвишава лимита {0}. +page.upload.validation.folderExceedsLimit=Папката надвишава лимита {0}. +page.upload.processingFolder=Обработване на папка... +page.upload.folderSelected=Избрана папка: {0} ({1} елемента) +page.upload.noCandidate=Няма наличен кандидат за качване. +form.keepIndefinitely=Запази безсрочно +form.keepIndefinitely.help.beforeDays=Ако е отметнато, файлът няма да бъде изтрит автоматично след +form.keepIndefinitely.help.afterDays=дни. +form.hideFromList=Скрий от списъка +form.hideFromList.help=Ако е отметнато, файлът няма да се показва на страницата View Files. +page.files.keepIndefinitely=Запази безсрочно: +page.files.passwordProtected=Защитено с парола: +upload.warning.privacyNotice=Всички познати метаданни ще бъдат премахнати, но този формат може да съдържа данни, които да разкрият самоличността ви. +upload.warning.technicalInfo=Техническа информация +# Page: Settings +page.settings.title.browser={0} Админ настройки +page.settings.adminTitle=Админ настройки +# Errors +# Notifications +notification.upload.failed=Качването не бе успешно. Моля, опитайте отново. +notification.upload.noFileInfo=Качването приключи, но сървърът не върна информация за файла. +notification.selection.required=Първо изберете файл или папка за качване. +notification.folder.processingFailed=Папката не може да бъде подготвена за качване. +notification.copied=Копирано +notification.failed=Неуспешно +notification.settings.saved=Настройките са запазени. +notification.target.unknown=Непозната цел за известяване. +# Validation +validation.required=Това поле е задължително. +validation.number.positive=Въведете стойност по-голяма от 0. +validation.number.nonNegative=Въведете стойност по-голяма или равна на 0. +validation.cron.invalid=Невалиден cron израз. +validation.cron.invalid.example=Невалиден cron израз. Моля, въведете валиден шаблон (например 0 0 2 * * * за 2:00 всеки ден). +validation.cron.nextRun.none=Няма предстоящо изпълнение. +validation.cron.unableToParse=Неуспешно разчитане на cron израз. +# Phase 3 incremental migration keys +button.submit=Изпрати +button.view=Преглед +button.history=История +button.download=Свали +form.password.label=Парола +form.password.placeholder=Парола +form.password.file.placeholder=Въведете паролата за файла... +form.adminPassword.label=Админ парола +form.adminPassword.placeholder=Админ парола +page.password.title.browser={0} - Парола +page.password.enterFile=Въведете парола +page.password.enterAppPrompt=Въведете паролата за приложението, за да продължите. +page.password.protectedLabel=Защитено +page.adminPassword.title.browser={0} - Админ вход +page.adminPassword.title=Изисква се админ парола +page.files.title.browser={0} - Файлове +page.files.title=Всички файлове +page.files.search.label=Търсене на файлове +page.files.search.placeholder=Търсете файлове по име или дата... +page.files.empty.title=Все още няма качени файлове. +page.files.empty.startBy=Започнете с +page.files.empty.uploadLink=качване на файл +page.files.goToFilePage=Към страницата на файла +pagination.previous=Предишна +pagination.next=Следваща +pagination.perPage=На страница +pagination.page=Страница +pagination.of=от +page.fileView.title.browser={0} - Изглед на файл +page.fileView.title=Изглед на файл +page.dashboard.title.browser={0} Админ +page.dashboard.title=Админ табло +page.dashboard.analytics=Анализ +page.dashboard.files=Файлове +page.dashboard.totalDownloads=Общо изтегляния +page.dashboard.excludingDeleted=Без изтритите файлове +page.dashboard.totalSpaceUsed=Общо използвано място +page.dashboard.averageFileSize=Среден размер на файл +page.dashboard.search.label=Търсене на файлове +page.dashboard.search.placeholder=Търсене по име, описание или UUID +table.name=Име +table.uploadDateLastRenewed=Дата на качване/последно подновяване +table.size=Размер +table.downloads=Изтегляния +page.error.title.browser={0} - Грешка +page.error.title=Опа! +page.error.message=Нещо се обърка. Моля, опитайте отново по-късно. +page.error.goHome=Назад към началната страница +# Phase 7 full-page translation keys +nav.language=Език +page.welcome.title.browser=Добре дошли в {0} +page.welcome.title=Добре дошли в {0} +page.welcome.setupThanks=Благодарим, че настроихте {0}! +page.welcome.setupPrompt=Моля, задайте админ парола за таблото. +page.welcome.passwordMismatch=Паролите не съвпадат. +page.welcome.setAdminPassword=Задай админ парола +form.password.confirm=Потвърди парола +form.password.confirm.placeholder=Потвърди парола +page.history.title.browser={0} Админ - История +page.history.title=История +page.history.downloadHistory=История на изтеглянията +page.history.fileDetails=Детайли за файла +page.history.size=Размер +page.history.uploadDate=Дата на качване +page.history.totalDownloads=Общо изтегляния +page.history.date=Дата +page.history.action=Действие +page.history.ipAddress=IP адрес +page.history.userAgent=Потребителски агент +page.history.noDescription=Няма описание +page.share.title.browser={0} - Споделен файл +page.share.title=Споделен файл +page.share.uploaded=Качен +page.share.fileSize=Размер на файла +page.invalidShare.title.browser={0} - Невалиден линк +page.invalidShare.title=Линкът е изтекъл +page.invalidShare.message=Този линк за споделяне вече не е валиден. Файлът, до който се опитвате да получите достъп, е изтекъл или линкът вече е използван. +page.invalidShare.backHome=Към началната страница +page.paste.title.browser={0} - Pastebin +page.paste.newTitle=Нова бележка +page.paste.editTitle=Редакция на бележка +page.paste.field.title=Заглавие +page.paste.field.mode=Режим +page.paste.mode.markdown=Markdown (.md) +page.paste.mode.text=Обикновен текст (.txt) +page.paste.lineNumbers=Номера на редове +page.paste.wordWrap=Пренасяне на редове +page.paste.fontSize=Размер на шрифта +page.paste.preview=Преглед +page.paste.content.placeholder=Напишете текста тук... +page.paste.password.label=Парола (по избор) +page.paste.password.placeholder=Задайте парола за защита на тази бележка +page.paste.password.help=Ако оставите полето празно, защитата с парола за записа ще бъде премахната. +page.paste.save=Запази бележката +page.paste.saveChanges=Запази промените +page.pasteView.title.browser={0} - Преглед на бележка +page.pasteView.untitled=Бележка без заглавие +page.pasteView.copyText=Копирай текста +page.pasteView.raw=Суров текст +page.pasteView.markdown=Markdown +page.pasteView.renderedHint=За тази бележка е активиран рендериран markdown. +page.pasteView.share=Споделяне +page.pasteView.generateLink=Генерирай линк +page.pasteView.copyShareLink=Копирай линка +page.pasteView.generateLinkFailed=Неуспешно генериране на линк за споделяне +page.fileView.keepIndefinitely=Запази безсрочно: +page.fileView.hideFromList=Скрий файла от списъка: +page.fileView.fileSize=Размер на файла: +page.fileView.folderContents=Съдържание на папка +page.fileView.editPaste=Редактирай бележката +page.fileView.preview=Преглед +page.fileView.share=Споделяне +page.fileView.shareBypassNotice=Линковете за споделяне винаги заобикалят паролите за файл и приложение. +page.fileView.daysValid=Дни валидност +page.fileView.allowedDownloads=Разрешени изтегляния +page.fileView.autoShareLink=Автоматичен линк за споделяне +page.fileView.uploadedAt=Качен на: +page.fileView.uploadedRenewedAt=Качен/подновен на: +page.fileView.retention.beforeDays=Файловете се пазят само +page.fileView.retention.afterDays=дни след тази дата. +page.fileView.preparingDownload=Файлът ви се подготвя за изтегляне. Моля, изчакайте... +page.fileView.renewLifetime=Поднови живота на файла +page.fileView.deleteFile=Изтрий файл +page.fileView.loadPreview=Зареди преглед ({0} MB лимит) +page.fileView.preview.clickToLoad=Натиснете „Зареди преглед“, за да заредите. +page.fileView.preview.loading=Зареждане на преглед... +page.fileView.shareAdvancedHint=Добавете дата на изтичане и/или лимит на изтеглянията, или изберете неограничено. +page.fileView.shareSimplifiedHint=Опростените линкове за споделяне са активирани: автоматично се генерира неограничаващ линк без изтичане. +page.fileView.noExpiration=Без изтичане (неограничено време) +page.fileView.unlimitedDownloads=Неограничени изтегляния +page.fileView.sharePlaceholder=Генерирайте линк за споделяне, който заобикаля паролите +page.fileView.shareDisabled=Линковете за споделяне са деактивирани от администратора. +page.fileView.daysValidNonNegative=Дните на валидност не могат да са отрицателни. +page.fileView.allowedDownloadsNonNegative=Разрешените изтегляния не могат да са отрицателни. +page.fileView.shareGenerateFailed=Неуспешно генериране на линк за споделяне. +page.fileView.preview.unavailable=Прегледът не е наличен. +page.fileView.preview.imageAlt=Преглед +page.fileView.preview.openInNewTab=Отвори {0} в нов раздел +page.fileView.preview.jsonFormatted=Форматиран +page.fileView.preview.jsonTree=Дърво +page.fileView.preview.csvNoRows=Няма редове за показване. +page.fileView.preview.csvShowingFirst=Показани са първите {0} от общо {1} реда. +page.fileView.preview.codeTruncated=... (съкратено) +page.fileView.folderFallbackName=папка +page.fileView.folderNoManifest=Няма наличен манифест. +page.fileView.folderRenderFailed=Неуспешно визуализиране на съдържанието на папката. +page.dashboard.keepIndefinitely=Запази безсрочно +page.dashboard.hidden=Скрит +page.dashboard.noFilesFound=Няма намерени файлове. +page.dashboard.deleteConfirm=Сигурни ли сте, че искате да изтриете този файл? Това действие не може да бъде отменено. +page.settings.branding.title=Брандинг +page.settings.branding.appName=Име на приложението +page.settings.branding.appName.placeholder=Моята марка +page.settings.branding.appName.help=Показва се в навигацията, заглавията и изгледите за споделяне. Оставете празно, за да върнете QuickDrop. +page.settings.branding.logo=Лого / favicon +page.settings.branding.logo.browse=Преглед +page.settings.branding.logo.reset=Нулирай към стандартното лого +page.settings.branding.logo.resetPending=Стандартното лого ще бъде възстановено при запазване. +page.settings.branding.logo.help=Качете квадратно изображение за най-добри резултати. Оставете празно, за да запазите текущото лого. +page.settings.file.title=Настройки на файловете +page.settings.file.maxSizeMb=Максимален размер на файл (MB) +page.settings.file.maxLifetimeDays=Максимален живот на файл (дни) +page.settings.file.storagePath=Път за съхранение на файлове +page.settings.file.disablePreview=Изключи прегледите на файлове +page.settings.file.disablePreview.help=Скрива вградените прегледи за изображения/текст на страницата на файла. +page.settings.file.maxPreviewMb=Максимален размер за преглед (MB) +page.settings.file.maxPreviewMb.help=Над този размер потребителите трябва да натиснат "Зареди преглед". +page.settings.file.keepIndefinitely.title=Запазване безсрочно +page.settings.file.keepIndefinitely.adminOnly=Изисквай админ сесия за "Запази безсрочно" +page.settings.file.keepIndefinitely.adminOnly.help=Когато е включено, само админи с активна сесия могат да маркират качванията като безсрочни. +page.settings.file.hideFromList.title=Скриване от списъка +page.settings.file.hideFromList.adminOnly=Изисквай админ сесия за скриване на файлове +page.settings.file.hideFromList.adminOnly.help=Когато е включено, само админи могат да променят "Скрий от файловия списък". +page.settings.system.title=Системни настройки +page.settings.system.sessionLifetimeMinutes=Продължителност на сесията (минути) +page.settings.system.sessionLifetimeMinutes.help=Влияе колко дълго се пазят файлови и админ сесии. +page.settings.system.defaultHomePage=Начална страница по подразбиране +page.settings.system.defaultHomePage.upload=Страница за качване +page.settings.system.defaultHomePage.list=Страница със списък на файлове +page.settings.system.defaultHomePage.paste=Страница Pastebin +page.settings.system.fileDeletionCron=Cron израз за изтриване на файлове +page.settings.system.nextRun=Следващо изпълнение +page.settings.system.fileList=Списък с файлове +page.settings.system.fileList.enabled=Разреши страницата със списък на файлове +page.settings.system.adminDashboard=Админ табло +page.settings.system.adminDashboard.showButton=Покажи бутон за админ табло +page.settings.system.adminDashboard.directUrl=Все още достъпно на /admin/dashboard +page.settings.system.pastebin=Pastebin +page.settings.system.pastebin.enabled=Разреши страницата Pastebin +page.settings.system.pastebin.help=Добавя таб за текстов редактор и позволява избор на Pastebin като начална страница. +page.settings.notifications.title=Настройки за известия +page.settings.notifications.discord.title=Discord webhook +page.settings.notifications.discord.help=Публикува файлова активност в Discord канал. +page.settings.notifications.discord.webhookUrl=Webhook URL +page.settings.notifications.discord.webhookUrl.placeholder=https://discord.com/api/webhooks/... +page.settings.notifications.discord.testButton=Изпрати тест в Discord +page.settings.notifications.email.title=Имейл известия +page.settings.notifications.email.help=Изпраща имейли при качване, изтегляне и подновяване. +page.settings.notifications.email.fromAddress=Адрес подател +page.settings.notifications.email.fromAddress.placeholder=noreply@example.com +page.settings.notifications.email.recipients=Получатели (разделени със запетая) +page.settings.notifications.email.recipients.placeholder=admin@example.com, alerts@example.com +page.settings.notifications.email.smtpHost=SMTP хост +page.settings.notifications.email.smtpHost.placeholder=smtp.example.com +page.settings.notifications.email.smtpPort=SMTP порт +page.settings.notifications.email.smtpUsername=SMTP потребител +page.settings.notifications.email.smtpPassword=SMTP парола +page.settings.notifications.email.useStartTls=Използвай STARTTLS +page.settings.notifications.email.useImplicitSsl=Използвай Implicit SSL (SMTPS) +page.settings.notifications.email.tlsHelp=Използвайте STARTTLS с порт 587. Използвайте Implicit SSL с порт 465. +page.settings.notifications.email.testButton=Изпрати тестов имейл +page.settings.notifications.batch.title=Групови известия +page.settings.notifications.batch.help=Групира събитията и изпраща на всеки N минути. Изисква поне един активен канал. +page.settings.notifications.batch.interval=Интервал за групиране (минути) +page.settings.notifications.testInProgress=Тестване... +page.settings.notifications.requestFailed=Заявката се провали. Вижте логовете. +page.settings.security.title=Настройки за сигурност +page.settings.security.appPasswordEnabled=Включи защита с парола +page.settings.security.appPasswordEnabled.help=Защитава цялото приложение с парола +page.settings.security.appPassword=Парола за приложение +page.settings.security.disableUploadPasswords=Забрани пароли при качване +page.settings.security.disableUploadPasswords.help=Не позволява на потребителите да задават пароли при качване (и изключва криптирането по файл). +page.settings.security.disableShareLinks=Забрани линковете за споделяне +page.settings.security.disableShareLinks.help=Скрива опциите за споделяне и блокира генерирането на нови линкове. +page.settings.security.simplifyShareLinks=Опрости линковете за споделяне +page.settings.security.simplifyShareLinks.help=Автоматично генерира неограничен линк без изтичане и скрива ръчните опции. +page.settings.security.disableFileEncryption=Забрани криптирането на файлове +page.settings.security.disableFileEncryption.help=Ако е включено, файловете няма да се криптират дори при защита с парола. +page.settings.security.metadataStripping=Премахване на метаданни +page.settings.security.metadataStripping.help=Премахва допълнителни файлови метаданни, когато е възможно. +page.settings.save=Запази настройките +page.settings.about.title=Относно {0} +page.settings.about.version=Версия +page.settings.about.database=База данни +page.settings.about.database.sqlite=SQLite +page.settings.about.javaVersion=Java версия +page.settings.about.osInfo=Информация за ОС +page.settings.about.unknown=Неизвестно +page.settings.about.notAvailable=Няма данни +validation.settings.maxFileSize=Въведете максимален размер на файл (MB), по-голям от 0. +validation.settings.maxFileLifetime=Въведете максимален живот на файл (дни), по-голям от 0. +validation.settings.fileStoragePathRequired=Пътят за съхранение на файлове е задължителен. +validation.settings.cronRequired=Cron изразът е задължителен. +validation.settings.sessionLifetime=Въведете продължителност на сесията в минути (положително число). +validation.settings.previewSize=Въведете размер за преглед (MB), по-голям от 0. +validation.settings.defaultHomePage=Изберете upload, list или paste. +validation.settings.appPasswordRequired=Паролата за приложението е задължителна при включена защита. +validation.settings.discordWebhook=Въведете валиден Discord webhook URL. +validation.settings.emailFrom=Въведете валиден имейл подател. +validation.settings.emailRecipients=Въведете поне един получател. +validation.settings.smtpHost=SMTP хостът е задължителен. +validation.settings.smtpPort=Въведете валиден SMTP порт. +validation.settings.enableChannelBeforeBatch=Включете Discord или Email преди групиране. +validation.settings.batchInterval=Въведете интервал за групиране в минути. +validation.settings.failed=Валидирането е неуспешно +page.paste.content.empty=Съдържанието на пастата не може да бъде празно. +form.password.show=Покажи парола +page.files.keepIndefinitely=Запази безсрочно: +page.files.passwordProtected=Защитено с парола: +page.settings.notifications.discord.missingUrl=URL адресът на Discord уебхука не е конфигуриран. +page.settings.notifications.discord.success=Изпратено е тестово известие до Discord. +page.settings.notifications.discord.failed=Discord тестът се провали: {0} +page.settings.notifications.email.incomplete=Имейл настройките са непълни (хост/подател/получатели). +page.settings.notifications.email.success=Изпратено е тестово имейл известие. +page.settings.notifications.email.failed=Имейл тестът се провали: {0} +page.settings.notifications.testFailed=???????? ? ?????????. ????? ????????. diff --git a/src/main/resources/messages_de.properties b/src/main/resources/messages_de.properties new file mode 100644 index 0000000..94e3a72 --- /dev/null +++ b/src/main/resources/messages_de.properties @@ -0,0 +1,335 @@ +# Common reusable labels +button.cancel=Abbrechen +button.upload=Hochladen +button.delete=Loeschen +button.edit=Bearbeiten +button.search=Suchen +button.selectFile=Datei auswaehlen +button.selectFolder=Ordner auswaehlen +button.copy=Kopieren +button.confirm=Bestaetigen +form.description.placeholder=Beschreibung +form.password.optional=Passwort (optional) +# Navigation +nav.viewFiles=Dateien anzeigen +nav.pastebin=Pastebin +nav.adminDashboard=Admin-Dashboard +nav.logout=Abmelden +nav.poweredBy=Powered by QuickDrop +nav.uploadFile=Datei hochladen +nav.settings=Einstellungen +nav.dashboard=Dashboard +nav.toggleNavigation=Navigation umschalten +# Page: Upload +page.upload.title.browser={0} - Upload +page.upload.title=Datei oder Ordner hochladen +page.upload.acceptedTypes=Akzeptierte Dateitypen: alle +page.upload.maxSizeLabel=Max. Groesse +page.upload.dropzone.title=Datei oder Ordner hier ablegen +page.upload.encryptionNotice=Alle passwortgeschuetzten Dateien werden zusaetzlich verschluesselt. +page.upload.metadataNotice=Metadaten-Entfernung ist aktiviert. Unterstuetzt: +page.upload.retention.standard=Dateien werden nach {0} Tagen geloescht, sofern nicht "Unbegrenzt aufbewahren" gewaehlt ist. +page.upload.retention.adminOnly=Dateien werden nach {0} Tagen geloescht. +page.upload.status.started=Upload gestartet... +page.upload.validation.fileExceedsLimit=Datei ueberschreitet das Limit {0}. +page.upload.validation.folderExceedsLimit=Ordner ueberschreitet das Limit {0}. +page.upload.processingFolder=Ordner wird verarbeitet... +page.upload.folderSelected=Ordner ausgewaehlt: {0} ({1} Elemente) +page.upload.noCandidate=Kein Upload-Kandidat verfuegbar. +form.keepIndefinitely=Unbegrenzt aufbewahren +form.keepIndefinitely.help.beforeDays=Wenn aktiviert, wird diese Datei nicht automatisch geloescht nach +form.keepIndefinitely.help.afterDays=Tagen. +form.hideFromList=Aus Dateiliste ausblenden +form.hideFromList.help=Wenn aktiviert, erscheint diese Datei nicht auf der Seite View Files. +page.files.keepIndefinitely=Unbegrenzt aufbewahren: +page.files.passwordProtected=Passwortgeschuetzt: +upload.warning.privacyNotice=Alle bekannten Metadaten werden entfernt, aber dieses Format kann weiterhin Details enthalten, die Informationen zu Ihrer Identitaet preisgeben. +upload.warning.technicalInfo=Technische Details +# Page: Settings +page.settings.title.browser={0} Admin-Einstellungen +page.settings.adminTitle=Admin-Einstellungen +# Errors +# Notifications +notification.upload.failed=Upload fehlgeschlagen. Bitte versuche es erneut. +notification.upload.noFileInfo=Upload abgeschlossen, aber der Server hat keine Dateiinformationen zurueckgegeben. +notification.selection.required=Waehle zuerst eine Datei oder einen Ordner aus. +notification.folder.processingFailed=Ordner konnte nicht fuer den Upload vorbereitet werden. +notification.copied=Kopiert +notification.failed=Fehlgeschlagen +notification.settings.saved=Einstellungen gespeichert. +notification.target.unknown=Unbekanntes Benachrichtigungsziel. +# Validation +validation.required=Dieses Feld ist erforderlich. +validation.number.positive=Bitte gib einen Wert groesser als 0 ein. +validation.number.nonNegative=Bitte gib einen Wert groesser oder gleich 0 ein. +validation.cron.invalid=Ungueltiger Cron-Ausdruck. +validation.cron.invalid.example=Ungueltiger Cron-Ausdruck. Bitte gib ein gueltiges Muster ein (z. B. 0 0 2 * * * fuer taeglich um 2:00 Uhr). +validation.cron.nextRun.none=Keine bevorstehende Ausfuehrung. +validation.cron.unableToParse=Cron-Ausdruck konnte nicht verarbeitet werden. +# Phase 3 incremental migration keys +button.submit=Senden +button.view=Ansehen +button.history=Verlauf +button.download=Herunterladen +form.password.label=Passwort +form.password.placeholder=Passwort +form.password.file.placeholder=Dateipasswort eingeben... +form.adminPassword.label=Admin-Passwort +form.adminPassword.placeholder=Admin-Passwort +page.password.title.browser={0} - Passwort +page.password.enterFile=Passwort eingeben +page.password.enterAppPrompt=Bitte gib das App-Passwort ein, um fortzufahren. +page.password.protectedLabel=Geschuetzt +page.adminPassword.title.browser={0} - Admin-Anmeldung +page.adminPassword.title=Admin-Passwort erforderlich +page.files.title.browser={0} - Dateien +page.files.title=Alle Dateien +page.files.search.label=Dateien suchen +page.files.search.placeholder=Dateien nach Name oder Datum suchen... +page.files.empty.title=Es wurden noch keine Dateien hochgeladen. +page.files.empty.startBy=Starte mit +page.files.empty.uploadLink=dem Hochladen einer Datei +page.files.goToFilePage=Zur Dateiseite +pagination.previous=Zurueck +pagination.next=Weiter +pagination.perPage=Pro Seite +pagination.page=Seite +pagination.of=von +page.fileView.title.browser={0} - Dateiansicht +page.fileView.title=Dateiansicht +page.dashboard.title.browser={0} Admin +page.dashboard.title=Admin-Dashboard +page.dashboard.analytics=Analysen +page.dashboard.files=Dateien +page.dashboard.totalDownloads=Downloads gesamt +page.dashboard.excludingDeleted=Ohne geloeschte Dateien +page.dashboard.totalSpaceUsed=Gesamter Speicherverbrauch +page.dashboard.averageFileSize=Durchschnittliche Dateigroesse +page.dashboard.search.label=Dateien suchen +page.dashboard.search.placeholder=Suche nach Name, Beschreibung oder UUID +table.name=Name +table.uploadDateLastRenewed=Upload-Datum/zuletzt verlaengert +table.size=Groesse +table.downloads=Downloads +page.error.title.browser={0} - Fehler +page.error.title=Hoppla! +page.error.message=Etwas ist schiefgelaufen. Bitte versuche es spaeter erneut. +page.error.goHome=Zurueck zur Startseite +# Phase 7 full-page translation keys +nav.language=Sprache +page.welcome.title.browser=Willkommen bei {0} +page.welcome.title=Willkommen bei {0} +page.welcome.setupThanks=Danke, dass du {0} eingerichtet hast! +page.welcome.setupPrompt=Bitte lege ein Admin-Passwort fuer das Dashboard fest. +page.welcome.passwordMismatch=Passwoerter stimmen nicht ueberein. +page.welcome.setAdminPassword=Admin-Passwort festlegen +form.password.confirm=Passwort bestaetigen +form.password.confirm.placeholder=Passwort bestaetigen +page.history.title.browser={0} Admin - Verlauf +page.history.title=Verlauf +page.history.downloadHistory=Download-Verlauf +page.history.fileDetails=Dateidetails +page.history.size=Groesse +page.history.uploadDate=Upload-Datum +page.history.totalDownloads=Downloads gesamt +page.history.date=Datum +page.history.action=Aktion +page.history.ipAddress=IP-Adresse +page.history.userAgent=User-Agent +page.history.noDescription=Keine Beschreibung vorhanden +page.share.title.browser={0} - Freigegebene Datei +page.share.title=Freigegebene Datei +page.share.uploaded=Hochgeladen +page.share.fileSize=Dateigroesse +page.invalidShare.title.browser={0} - Ungueltiger Link +page.invalidShare.title=Link abgelaufen +page.invalidShare.message=Dieser Freigabelink ist nicht mehr gueltig. Die Datei ist abgelaufen oder der Link wurde bereits verwendet. +page.invalidShare.backHome=Zurueck zur Startseite +page.paste.title.browser={0} - Pastebin +page.paste.newTitle=Neues Paste +page.paste.editTitle=Paste bearbeiten +page.paste.field.title=Titel +page.paste.field.mode=Modus +page.paste.mode.markdown=Markdown (.md) +page.paste.mode.text=Klartext (.txt) +page.paste.lineNumbers=Zeilennummern +page.paste.wordWrap=Zeilenumbruch +page.paste.fontSize=Schriftgroesse +page.paste.preview=Vorschau +page.paste.content.placeholder=Schreibe hier deinen Text... +page.paste.password.label=Passwort (optional) +page.paste.password.placeholder=Passwort zum Schutz dieses Paste setzen +page.paste.password.help=Wenn dieses Feld leer bleibt, wird der Passwortschutz entfernt. +page.paste.save=Paste speichern +page.paste.saveChanges=Aenderungen speichern +page.pasteView.title.browser={0} - Paste-Ansicht +page.pasteView.untitled=Unbenanntes Paste +page.pasteView.copyText=Text kopieren +page.pasteView.raw=Rohtext +page.pasteView.markdown=Markdown +page.pasteView.renderedHint=Gerendertes Markdown ist fuer dieses Paste aktiviert. +page.pasteView.share=Teilen +page.pasteView.generateLink=Link erzeugen +page.pasteView.copyShareLink=Freigabelink kopieren +page.pasteView.generateLinkFailed=Freigabelink konnte nicht erstellt werden +page.fileView.keepIndefinitely=Unbegrenzt aufbewahren: +page.fileView.hideFromList=Datei in Liste ausblenden: +page.fileView.fileSize=Dateigroesse: +page.fileView.folderContents=Ordnerinhalt +page.fileView.editPaste=Paste bearbeiten +page.fileView.preview=Vorschau +page.fileView.share=Teilen +page.fileView.shareBypassNotice=Freigabelinks umgehen immer Datei- und App-Passwoerter. +page.fileView.daysValid=Tage gueltig +page.fileView.allowedDownloads=Erlaubte Downloads +page.fileView.autoShareLink=Automatischer Freigabelink +page.fileView.uploadedAt=Hochgeladen am: +page.fileView.uploadedRenewedAt=Hochgeladen/verlaengert am: +page.fileView.retention.beforeDays=Dateien werden nur fuer +page.fileView.retention.afterDays=Tage nach diesem Datum aufbewahrt. +page.fileView.preparingDownload=Deine Datei wird fuer den Download vorbereitet. Bitte warten... +page.fileView.renewLifetime=Dateilebensdauer verlaengern +page.fileView.deleteFile=Datei loeschen +page.fileView.loadPreview=Vorschau laden ({0} MB Limit) +page.fileView.preview.clickToLoad=Auf "Vorschau laden" klicken, um zu laden. +page.fileView.preview.loading=Vorschau wird geladen... +page.fileView.shareAdvancedHint=Fuege ein Ablaufdatum und/oder Download-Limit hinzu oder waehle unbegrenzt fuer einen offenen Link. +page.fileView.shareSimplifiedHint=Vereinfachte Freigabelinks sind aktiviert: Ein nicht ablaufender Link mit unbegrenzten Downloads wird automatisch erstellt. +page.fileView.noExpiration=Kein Ablauf (unbegrenzte Zeit) +page.fileView.unlimitedDownloads=Unbegrenzte Downloads +page.fileView.sharePlaceholder=Erzeuge einen Freigabelink, der Passwoerter umgeht +page.fileView.shareDisabled=Freigabelinks wurden vom Administrator deaktiviert. +page.fileView.daysValidNonNegative=Tage gueltig duerfen nicht negativ sein. +page.fileView.allowedDownloadsNonNegative=Erlaubte Downloads duerfen nicht negativ sein. +page.fileView.shareGenerateFailed=Freigabelink konnte nicht erstellt werden. +page.fileView.preview.unavailable=Vorschau nicht verfuegbar. +page.fileView.preview.imageAlt=Vorschau +page.fileView.preview.openInNewTab=Oeffne {0} in einem neuen Tab +page.fileView.preview.jsonFormatted=Formatiert +page.fileView.preview.jsonTree=Baum +page.fileView.preview.csvNoRows=Keine Zeilen zum Anzeigen. +page.fileView.preview.csvShowingFirst=Zeige die ersten {0} von {1} Zeilen. +page.fileView.preview.codeTruncated=... (gekuerzt) +page.fileView.folderFallbackName=ordner +page.fileView.folderNoManifest=Kein Manifest verfuegbar. +page.fileView.folderRenderFailed=Ordnerinhalt konnte nicht dargestellt werden. +page.dashboard.keepIndefinitely=Unbegrenzt aufbewahren +page.dashboard.hidden=Ausgeblendet +page.dashboard.noFilesFound=Keine Dateien gefunden. +page.dashboard.deleteConfirm=Moechtest du diese Datei wirklich loeschen? Diese Aktion kann nicht rueckgaengig gemacht werden. +page.settings.branding.title=Branding +page.settings.branding.appName=Anwendungsname +page.settings.branding.appName.placeholder=Meine Marke +page.settings.branding.appName.help=Wird in Navigation, Seitentiteln und Freigabeansichten angezeigt. Leer lassen, um QuickDrop wiederherzustellen. +page.settings.branding.logo=Logo / Favicon +page.settings.branding.logo.browse=Durchsuchen +page.settings.branding.logo.reset=Auf Standardlogo zuruecksetzen +page.settings.branding.logo.resetPending=Das Standardlogo wird beim Speichern wiederhergestellt. +page.settings.branding.logo.help=Lade ein quadratisches Bild fuer beste Ergebnisse hoch. Leer lassen, um das aktuelle Logo zu behalten. +page.settings.file.title=Dateieinstellungen +page.settings.file.maxSizeMb=Maximale Dateigroesse (MB) +page.settings.file.maxLifetimeDays=Maximale Dateilebensdauer (Tage) +page.settings.file.storagePath=Dateispeicherpfad +page.settings.file.disablePreview=Dateivorschau deaktivieren +page.settings.file.disablePreview.help=Blendet Inline-Vorschauen fuer Bilder/Text auf der Dateiseite aus. +page.settings.file.maxPreviewMb=Maximale Vorschaugroesse (MB) +page.settings.file.maxPreviewMb.help=Ueber dieser Groesse muessen Benutzer "Vorschau laden" klicken. +page.settings.file.keepIndefinitely.title=Unbegrenzt aufbewahren +page.settings.file.keepIndefinitely.adminOnly=Admin-Sitzung fuer "Unbegrenzt aufbewahren" erforderlich +page.settings.file.keepIndefinitely.adminOnly.help=Wenn aktiviert, koennen nur Admins mit aktiver Sitzung Uploads unbegrenzt markieren. +page.settings.file.hideFromList.title=Aus Liste ausblenden +page.settings.file.hideFromList.adminOnly=Admin-Sitzung zum Ausblenden von Dateien erforderlich +page.settings.file.hideFromList.adminOnly.help=Wenn aktiviert, koennen nur Admins "Aus Dateiliste ausblenden" aendern. +page.settings.system.title=Systemeinstellungen +page.settings.system.sessionLifetimeMinutes=Sitzungsdauer (Minuten) +page.settings.system.sessionLifetimeMinutes.help=Beeinflusst, wie lange Datei- und Admin-Sitzungen erhalten bleiben. +page.settings.system.defaultHomePage=Standard-Startseite +page.settings.system.defaultHomePage.upload=Upload-Seite +page.settings.system.defaultHomePage.list=Dateiliste-Seite +page.settings.system.defaultHomePage.paste=Pastebin-Seite +page.settings.system.fileDeletionCron=Cron-Ausdruck fuer Dateiloeschung +page.settings.system.nextRun=Naechster Lauf +page.settings.system.fileList=Dateiliste +page.settings.system.fileList.enabled=Dateiliste-Seite aktivieren +page.settings.system.adminDashboard=Admin-Dashboard +page.settings.system.adminDashboard.showButton=Admin-Dashboard-Button anzeigen +page.settings.system.adminDashboard.directUrl=Weiterhin verfuegbar unter /admin/dashboard +page.settings.system.pastebin=Pastebin +page.settings.system.pastebin.enabled=Pastebin-Seite aktivieren +page.settings.system.pastebin.help=Fuegt einen Texteditor-Tab hinzu und erlaubt Pastebin als Startseite. +page.settings.notifications.title=Benachrichtigungseinstellungen +page.settings.notifications.discord.title=Discord-Webhook +page.settings.notifications.discord.help=Dateiaktivitaet in einen Discord-Kanal posten. +page.settings.notifications.discord.webhookUrl=Webhook-URL +page.settings.notifications.discord.webhookUrl.placeholder=https://discord.com/api/webhooks/... +page.settings.notifications.discord.testButton=Discord-Test senden +page.settings.notifications.email.title=E-Mail-Benachrichtigungen +page.settings.notifications.email.help=Sendet E-Mails bei Upload-, Download- und Erneuerungsereignissen. +page.settings.notifications.email.fromAddress=Absenderadresse +page.settings.notifications.email.fromAddress.placeholder=noreply@example.com +page.settings.notifications.email.recipients=Empfaenger (kommagetrennt) +page.settings.notifications.email.recipients.placeholder=admin@example.com, alerts@example.com +page.settings.notifications.email.smtpHost=SMTP-Host +page.settings.notifications.email.smtpHost.placeholder=smtp.example.com +page.settings.notifications.email.smtpPort=SMTP-Port +page.settings.notifications.email.smtpUsername=SMTP-Benutzername +page.settings.notifications.email.smtpPassword=SMTP-Passwort +page.settings.notifications.email.useStartTls=STARTTLS verwenden +page.settings.notifications.email.useImplicitSsl=Implizites SSL (SMTPS) verwenden +page.settings.notifications.email.tlsHelp=STARTTLS mit Port 587 verwenden. Implizites SSL mit Port 465 verwenden. +page.settings.notifications.email.testButton=Test-E-Mail senden +page.settings.notifications.batch.title=Batch-Benachrichtigungen +page.settings.notifications.batch.help=Ereignisse sammeln und alle N Minuten senden. Mindestens ein Kanal muss aktiviert sein. +page.settings.notifications.batch.interval=Batch-Intervall (Minuten) +page.settings.notifications.testInProgress=Teste... +page.settings.notifications.requestFailed=Anfrage fehlgeschlagen. Siehe Logs. +page.settings.security.title=Sicherheitseinstellungen +page.settings.security.appPasswordEnabled=Passwortschutz aktivieren +page.settings.security.appPasswordEnabled.help=Schuetzt die gesamte Anwendung mit einem Passwort +page.settings.security.appPassword=App-Passwort +page.settings.security.disableUploadPasswords=Upload-Passwoerter deaktivieren +page.settings.security.disableUploadPasswords.help=Verhindert Upload-Passwoerter (und deaktiviert dateibezogene Verschluesselung). +page.settings.security.disableShareLinks=Freigabelinks deaktivieren +page.settings.security.disableShareLinks.help=Blendet Freigabeoptionen aus und blockiert neue Freigabelinks. +page.settings.security.simplifyShareLinks=Freigabelinks vereinfachen +page.settings.security.simplifyShareLinks.help=Erzeugt automatisch einen unbegrenzten, nicht ablaufenden Freigabelink. +page.settings.security.disableFileEncryption=Dateiverschluesselung deaktivieren +page.settings.security.disableFileEncryption.help=Wenn aktiviert, werden Dateien nicht verschluesselt, selbst bei Passwortschutz. +page.settings.security.metadataStripping=Metadaten entfernen +page.settings.security.metadataStripping.help=Entfernt optionale Dateimetadaten, wenn moeglich. +page.settings.save=Einstellungen speichern +page.settings.about.title=Ueber {0} +page.settings.about.version=Version +page.settings.about.database=Datenbank +page.settings.about.database.sqlite=SQLite +page.settings.about.javaVersion=Java-Version +page.settings.about.osInfo=OS-Info +page.settings.about.unknown=Unbekannt +page.settings.about.notAvailable=N/V +validation.settings.maxFileSize=Gib eine maximale Dateigroesse (MB) groesser als 0 ein. +validation.settings.maxFileLifetime=Gib eine maximale Dateilebensdauer (Tage) groesser als 0 ein. +validation.settings.fileStoragePathRequired=Dateispeicherpfad ist erforderlich. +validation.settings.cronRequired=Cron-Ausdruck ist erforderlich. +validation.settings.sessionLifetime=Gib eine Sitzungsdauer in Minuten ein (positive Zahl). +validation.settings.previewSize=Gib eine Vorschaugroesse (MB) groesser als 0 ein. +validation.settings.defaultHomePage=Waehle upload, list oder paste. +validation.settings.appPasswordRequired=App-Passwort ist erforderlich, wenn Schutz aktiviert ist. +validation.settings.discordWebhook=Gib eine gueltige Discord-Webhook-URL ein. +validation.settings.emailFrom=Gib eine gueltige Absender-E-Mail ein. +validation.settings.emailRecipients=Gib mindestens einen Empfaenger ein. +validation.settings.smtpHost=SMTP-Host ist erforderlich. +validation.settings.smtpPort=Gib einen gueltigen SMTP-Port ein. +validation.settings.enableChannelBeforeBatch=Aktiviere Discord oder E-Mail vor Batch-Benachrichtigungen. +validation.settings.batchInterval=Gib ein Batch-Intervall in Minuten ein. +validation.settings.failed=Validierung fehlgeschlagen +page.paste.content.empty=Paste-Inhalt darf nicht leer sein. +form.password.show=Passwort anzeigen +page.files.keepIndefinitely=Unbegrenzt aufbewahren: +page.files.passwordProtected=Passwortgeschuetzt: +page.settings.notifications.discord.missingUrl=Discord-Webhook-URL ist nicht konfiguriert. +page.settings.notifications.discord.success=Discord-Testbenachrichtigung gesendet. +page.settings.notifications.discord.failed=Discord-Test fehlgeschlagen: {0} +page.settings.notifications.email.incomplete=E-Mail-Einstellungen unvollständig (Host/Absender/Empfänger). +page.settings.notifications.email.success=E-Mail-Testbenachrichtigung gesendet. +page.settings.notifications.email.failed=E-Mail-Test fehlgeschlagen: {0} +page.settings.notifications.testFailed=Anfrage fehlgeschlagen. Siehe Logs. diff --git a/src/main/resources/static/css/tailwind.css b/src/main/resources/static/css/tailwind.css index 788ac95..68782ae 100644 --- a/src/main/resources/static/css/tailwind.css +++ b/src/main/resources/static/css/tailwind.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-sky-900:oklch(39.1% .09 240.876);--color-blue-600:oklch(54.6% .245 262.881);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-xl:36rem;--container-4xl:56rem;--container-5xl:64rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.relative{position:relative}.static{position:static}.sticky{position:sticky}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing)*2)}.top-full{top:100%}.right-0{right:calc(var(--spacing)*0)}.right-3{right:calc(var(--spacing)*3)}.left-0{left:calc(var(--spacing)*0)}.z-10{z-index:10}.z-20{z-index:20}.col-span-12{grid-column:span 12/span 12}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:calc(var(--spacing)*0)}.-mx-2{margin-inline:calc(var(--spacing)*-2)}.-mx-6{margin-inline:calc(var(--spacing)*-6)}.mx-auto{margin-inline:auto}.my-5{margin-block:calc(var(--spacing)*5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-5{margin-top:calc(var(--spacing)*5)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-12{margin-top:calc(var(--spacing)*12)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-6{margin-left:calc(var(--spacing)*6)}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-2{height:calc(var(--spacing)*2)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-10{height:calc(var(--spacing)*10)}.h-12{height:calc(var(--spacing)*12)}.h-14{height:calc(var(--spacing)*14)}.h-full{height:100%}.max-h-32{max-height:calc(var(--spacing)*32)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[28rem\]{max-height:28rem}.max-h-\[72vh\]{max-height:72vh}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[28rem\]{max-width:28rem}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-\[8rem\]{min-width:8rem}.min-w-full{min-width:100%}.min-w-max{min-width:max-content}.flex-1{flex:1}.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.-translate-y-1\/2{--tw-translate-y:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*10)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*10)*calc(1 - var(--tw-space-y-reverse)))}.gap-x-3{column-gap:calc(var(--spacing)*3)}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}.gap-y-2{row-gap:calc(var(--spacing)*2)}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px*var(--tw-divide-x-reverse));border-inline-end-width:calc(1px*calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-200>:not(:last-child)){border-color:var(--color-slate-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-amber-200{border-color:var(--color-amber-200)}.border-amber-300{border-color:var(--color-amber-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-sky-500{border-color:var(--color-sky-500)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-slate-400\/70{border-color:#90a1b9b3}@supports (color:color-mix(in lab, red, red)){.border-slate-400\/70{border-color:color-mix(in oklab,var(--color-slate-400)70%,transparent)}}.border-t-transparent{border-top-color:#0000}.bg-amber-50{background-color:var(--color-amber-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-800{background-color:var(--color-gray-800)}.bg-green-600{background-color:var(--color-green-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-600{background-color:var(--color-red-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-500{background-color:var(--color-sky-500)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.bg-white\/70{background-color:color-mix(in oklab,var(--color-white)70%,transparent)}}.bg-yellow-100{background-color:var(--color-yellow-100)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-5{padding:calc(var(--spacing)*5)}.p-6{padding:calc(var(--spacing)*6)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-10{padding-block:calc(var(--spacing)*10)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-4{padding-top:calc(var(--spacing)*4)}.pr-12{padding-right:calc(var(--spacing)*12)}.pl-2{padding-left:calc(var(--spacing)*2)}.pl-5{padding-left:calc(var(--spacing)*5)}.text-center{text-align:center}.text-left{text-align:left}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-600{color:var(--color-amber-600)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-blue-600{color:var(--color-blue-600)}.text-gray-100{color:var(--color-gray-100)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-sky-800{color:var(--color-sky-800)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-white{color:var(--color-white)}.text-yellow-700{color:var(--color-yellow-700)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.placeholder-gray-500::placeholder{color:var(--color-gray-500)}.accent-sky-500{accent-color:var(--color-sky-500)}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-sky-500{--tw-ring-color:var(--color-sky-500)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}.focus-within\:border-sky-500:focus-within{border-color:var(--color-sky-500)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-within\:ring-sky-500:focus-within{--tw-ring-color:var(--color-sky-500)}@media (hover:hover){.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-600:hover{background-color:var(--color-sky-600)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-slate-300:hover{background-color:var(--color-slate-300)}.hover\:text-slate-600:hover{color:var(--color-slate-600)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}}.focus\:border-sky-500:focus{border-color:var(--color-sky-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-red-300:focus{--tw-ring-color:var(--color-red-300)}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-sky-500:focus-visible{--tw-ring-color:var(--color-sky-500)}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x)var(--tw-scale-y)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:40rem){.sm\:col-span-1{grid-column:span 1/span 1}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:col-span-3{grid-column:span 3/span 3}.sm\:w-auto{width:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:gap-4{gap:calc(var(--spacing)*4)}}@media (min-width:48rem){.md\:static{position:static}.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-8{grid-column:span 8/span 8}.md\:col-start-3{grid-column-start:3}.md\:mt-0{margin-top:calc(var(--spacing)*0)}.md\:ml-auto{margin-left:auto}.md\:flex{display:flex}.md\:hidden{display:none}.md\:w-auto{width:auto}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:gap-3{gap:calc(var(--spacing)*3)}:where(.md\:space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*0)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*0)*calc(1 - var(--tw-space-y-reverse)))}:where(.md\:space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*4)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-x-reverse)))}.md\:rounded-none{border-radius:0}.md\:bg-transparent{background-color:#0000}.md\:p-0{padding:calc(var(--spacing)*0)}.md\:p-5{padding:calc(var(--spacing)*5)}.md\:p-8{padding:calc(var(--spacing)*8)}.md\:py-8{padding-block:calc(var(--spacing)*8)}.md\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.md\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.md\:shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}@media (min-width:64rem){.lg\:col-span-6{grid-column:span 6/span 6}.lg\:col-start-4{grid-column-start:4}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:80rem){.xl\:col-span-1{grid-column:span 1/span 1}.xl\:col-span-4{grid-column:span 4/span 4}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}@media (prefers-color-scheme:dark){:where(.dark\:divide-slate-600>:not(:last-child)){border-color:var(--color-slate-600)}:where(.dark\:divide-slate-700>:not(:last-child)){border-color:var(--color-slate-700)}.dark\:border-amber-700{border-color:var(--color-amber-700)}.dark\:border-gray-600{border-color:var(--color-gray-600)}.dark\:border-red-800{border-color:var(--color-red-800)}.dark\:border-slate-500{border-color:var(--color-slate-500)}.dark\:border-slate-600{border-color:var(--color-slate-600)}.dark\:border-slate-700{border-color:var(--color-slate-700)}.dark\:bg-amber-950{background-color:var(--color-amber-950)}.dark\:bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/20{background-color:color-mix(in oklab,var(--color-black)20%,transparent)}}.dark\:bg-gray-700{background-color:var(--color-gray-700)}.dark\:bg-gray-800{background-color:var(--color-gray-800)}.dark\:bg-gray-900{background-color:var(--color-gray-900)}.dark\:bg-green-500{background-color:var(--color-green-500)}.dark\:bg-red-500{background-color:var(--color-red-500)}.dark\:bg-red-600{background-color:var(--color-red-600)}.dark\:bg-red-900{background-color:var(--color-red-900)}.dark\:bg-red-950{background-color:var(--color-red-950)}.dark\:bg-sky-400{background-color:var(--color-sky-400)}.dark\:bg-sky-900{background-color:var(--color-sky-900)}.dark\:bg-slate-700{background-color:var(--color-slate-700)}.dark\:bg-slate-800{background-color:var(--color-slate-800)}.dark\:bg-slate-900{background-color:var(--color-slate-900)}.dark\:bg-slate-900\/50{background-color:#0f172b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-slate-900\/50{background-color:color-mix(in oklab,var(--color-slate-900)50%,transparent)}}.dark\:bg-yellow-900{background-color:var(--color-yellow-900)}.dark\:text-amber-100{color:var(--color-amber-100)}.dark\:text-amber-200{color:var(--color-amber-200)}.dark\:text-amber-400{color:var(--color-amber-400)}.dark\:text-gray-100{color:var(--color-gray-100)}.dark\:text-gray-200{color:var(--color-gray-200)}.dark\:text-gray-300{color:var(--color-gray-300)}.dark\:text-gray-400{color:var(--color-gray-400)}.dark\:text-gray-500{color:var(--color-gray-500)}.dark\:text-green-400{color:var(--color-green-400)}.dark\:text-red-100{color:var(--color-red-100)}.dark\:text-red-200{color:var(--color-red-200)}.dark\:text-red-300{color:var(--color-red-300)}.dark\:text-red-400{color:var(--color-red-400)}.dark\:text-sky-100{color:var(--color-sky-100)}.dark\:text-sky-300{color:var(--color-sky-300)}.dark\:text-sky-400{color:var(--color-sky-400)}.dark\:text-slate-100{color:var(--color-slate-100)}.dark\:text-slate-300{color:var(--color-slate-300)}.dark\:text-slate-400{color:var(--color-slate-400)}.dark\:text-yellow-100{color:var(--color-yellow-100)}.dark\:placeholder-gray-400::placeholder{color:var(--color-gray-400)}.dark\:accent-sky-400{accent-color:var(--color-sky-400)}@media (hover:hover){.dark\:hover\:bg-green-600:hover{background-color:var(--color-green-600)}.dark\:hover\:bg-red-500:hover{background-color:var(--color-red-500)}.dark\:hover\:bg-red-600:hover{background-color:var(--color-red-600)}.dark\:hover\:bg-sky-500:hover{background-color:var(--color-sky-500)}.dark\:hover\:bg-slate-600:hover{background-color:var(--color-slate-600)}.dark\:hover\:bg-slate-700:hover{background-color:var(--color-slate-700)}.dark\:hover\:text-slate-200:hover{color:var(--color-slate-200)}}.dark\:focus\:ring-red-700:focus{--tw-ring-color:var(--color-red-700)}.dark\:focus\:ring-slate-600:focus{--tw-ring-color:var(--color-slate-600)}}@media (min-width:48rem){@media (prefers-color-scheme:dark){.md\:dark\:bg-transparent{background-color:#0000}}}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-sky-900:oklch(39.1% .09 240.876);--color-blue-600:oklch(54.6% .245 262.881);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-xl:36rem;--container-4xl:56rem;--container-5xl:64rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.relative{position:relative}.static{position:static}.sticky{position:sticky}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing)*2)}.top-full{top:100%}.right-0{right:calc(var(--spacing)*0)}.right-3{right:calc(var(--spacing)*3)}.left-0{left:calc(var(--spacing)*0)}.z-10{z-index:10}.z-20{z-index:20}.col-span-12{grid-column:span 12/span 12}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:calc(var(--spacing)*0)}.-mx-2{margin-inline:calc(var(--spacing)*-2)}.-mx-6{margin-inline:calc(var(--spacing)*-6)}.mx-auto{margin-inline:auto}.my-5{margin-block:calc(var(--spacing)*5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-5{margin-top:calc(var(--spacing)*5)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-12{margin-top:calc(var(--spacing)*12)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-6{margin-left:calc(var(--spacing)*6)}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-2{height:calc(var(--spacing)*2)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-10{height:calc(var(--spacing)*10)}.h-12{height:calc(var(--spacing)*12)}.h-14{height:calc(var(--spacing)*14)}.h-full{height:100%}.max-h-32{max-height:calc(var(--spacing)*32)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[28rem\]{max-height:28rem}.max-h-\[72vh\]{max-height:72vh}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[28rem\]{max-width:28rem}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-\[8rem\]{min-width:8rem}.min-w-full{min-width:100%}.min-w-max{min-width:max-content}.flex-1{flex:1}.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.-translate-y-1\/2{--tw-translate-y:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*10)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*10)*calc(1 - var(--tw-space-y-reverse)))}.gap-x-3{column-gap:calc(var(--spacing)*3)}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}.gap-y-2{row-gap:calc(var(--spacing)*2)}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px*var(--tw-divide-x-reverse));border-inline-end-width:calc(1px*calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-200>:not(:last-child)){border-color:var(--color-slate-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-amber-200{border-color:var(--color-amber-200)}.border-amber-300{border-color:var(--color-amber-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-sky-500{border-color:var(--color-sky-500)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-slate-400\/70{border-color:#90a1b9b3}@supports (color:color-mix(in lab, red, red)){.border-slate-400\/70{border-color:color-mix(in oklab,var(--color-slate-400)70%,transparent)}}.border-slate-600{border-color:var(--color-slate-600)}.border-t-transparent{border-top-color:#0000}.bg-amber-50{background-color:var(--color-amber-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-800{background-color:var(--color-gray-800)}.bg-green-600{background-color:var(--color-green-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-600{background-color:var(--color-red-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-500{background-color:var(--color-sky-500)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-800{background-color:var(--color-slate-800)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.bg-white\/70{background-color:color-mix(in oklab,var(--color-white)70%,transparent)}}.bg-yellow-100{background-color:var(--color-yellow-100)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-5{padding:calc(var(--spacing)*5)}.p-6{padding:calc(var(--spacing)*6)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-10{padding-block:calc(var(--spacing)*10)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-4{padding-top:calc(var(--spacing)*4)}.pr-12{padding-right:calc(var(--spacing)*12)}.pl-2{padding-left:calc(var(--spacing)*2)}.pl-5{padding-left:calc(var(--spacing)*5)}.text-center{text-align:center}.text-left{text-align:left}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-600{color:var(--color-amber-600)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-blue-600{color:var(--color-blue-600)}.text-gray-100{color:var(--color-gray-100)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-sky-800{color:var(--color-sky-800)}.text-slate-100{color:var(--color-slate-100)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-white{color:var(--color-white)}.text-yellow-700{color:var(--color-yellow-700)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.placeholder-gray-500::placeholder{color:var(--color-gray-500)}.accent-sky-500{accent-color:var(--color-sky-500)}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-sky-500{--tw-ring-color:var(--color-sky-500)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}.focus-within\:border-sky-500:focus-within{border-color:var(--color-sky-500)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-within\:ring-sky-500:focus-within{--tw-ring-color:var(--color-sky-500)}@media (hover:hover){.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-600:hover{background-color:var(--color-sky-600)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-slate-300:hover{background-color:var(--color-slate-300)}.hover\:text-slate-600:hover{color:var(--color-slate-600)}.hover\:text-white:hover{color:var(--color-white)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}}.focus\:border-sky-500:focus{border-color:var(--color-sky-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-red-300:focus{--tw-ring-color:var(--color-red-300)}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-sky-500:focus-visible{--tw-ring-color:var(--color-sky-500)}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x)var(--tw-scale-y)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:40rem){.sm\:col-span-1{grid-column:span 1/span 1}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:col-span-3{grid-column:span 3/span 3}.sm\:w-auto{width:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:gap-4{gap:calc(var(--spacing)*4)}}@media (min-width:48rem){.md\:static{position:static}.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-8{grid-column:span 8/span 8}.md\:col-start-3{grid-column-start:3}.md\:mt-0{margin-top:calc(var(--spacing)*0)}.md\:ml-auto{margin-left:auto}.md\:flex{display:flex}.md\:hidden{display:none}.md\:w-auto{width:auto}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:gap-3{gap:calc(var(--spacing)*3)}:where(.md\:space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*0)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*0)*calc(1 - var(--tw-space-y-reverse)))}:where(.md\:space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*4)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-x-reverse)))}.md\:rounded-none{border-radius:0}.md\:bg-transparent{background-color:#0000}.md\:p-0{padding:calc(var(--spacing)*0)}.md\:p-5{padding:calc(var(--spacing)*5)}.md\:p-8{padding:calc(var(--spacing)*8)}.md\:py-8{padding-block:calc(var(--spacing)*8)}.md\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.md\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.md\:shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}@media (min-width:64rem){.lg\:col-span-6{grid-column:span 6/span 6}.lg\:col-start-4{grid-column-start:4}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:80rem){.xl\:col-span-1{grid-column:span 1/span 1}.xl\:col-span-4{grid-column:span 4/span 4}.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}}@media (prefers-color-scheme:dark){:where(.dark\:divide-slate-600>:not(:last-child)){border-color:var(--color-slate-600)}:where(.dark\:divide-slate-700>:not(:last-child)){border-color:var(--color-slate-700)}.dark\:border-amber-700{border-color:var(--color-amber-700)}.dark\:border-gray-600{border-color:var(--color-gray-600)}.dark\:border-red-800{border-color:var(--color-red-800)}.dark\:border-slate-500{border-color:var(--color-slate-500)}.dark\:border-slate-600{border-color:var(--color-slate-600)}.dark\:border-slate-700{border-color:var(--color-slate-700)}.dark\:bg-amber-950{background-color:var(--color-amber-950)}.dark\:bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/20{background-color:color-mix(in oklab,var(--color-black)20%,transparent)}}.dark\:bg-gray-700{background-color:var(--color-gray-700)}.dark\:bg-gray-800{background-color:var(--color-gray-800)}.dark\:bg-gray-900{background-color:var(--color-gray-900)}.dark\:bg-green-500{background-color:var(--color-green-500)}.dark\:bg-red-500{background-color:var(--color-red-500)}.dark\:bg-red-600{background-color:var(--color-red-600)}.dark\:bg-red-900{background-color:var(--color-red-900)}.dark\:bg-red-950{background-color:var(--color-red-950)}.dark\:bg-sky-400{background-color:var(--color-sky-400)}.dark\:bg-sky-900{background-color:var(--color-sky-900)}.dark\:bg-slate-700{background-color:var(--color-slate-700)}.dark\:bg-slate-800{background-color:var(--color-slate-800)}.dark\:bg-slate-900{background-color:var(--color-slate-900)}.dark\:bg-slate-900\/50{background-color:#0f172b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-slate-900\/50{background-color:color-mix(in oklab,var(--color-slate-900)50%,transparent)}}.dark\:bg-yellow-900{background-color:var(--color-yellow-900)}.dark\:text-amber-100{color:var(--color-amber-100)}.dark\:text-amber-200{color:var(--color-amber-200)}.dark\:text-amber-400{color:var(--color-amber-400)}.dark\:text-gray-100{color:var(--color-gray-100)}.dark\:text-gray-200{color:var(--color-gray-200)}.dark\:text-gray-300{color:var(--color-gray-300)}.dark\:text-gray-400{color:var(--color-gray-400)}.dark\:text-gray-500{color:var(--color-gray-500)}.dark\:text-green-400{color:var(--color-green-400)}.dark\:text-red-100{color:var(--color-red-100)}.dark\:text-red-200{color:var(--color-red-200)}.dark\:text-red-300{color:var(--color-red-300)}.dark\:text-red-400{color:var(--color-red-400)}.dark\:text-sky-100{color:var(--color-sky-100)}.dark\:text-sky-300{color:var(--color-sky-300)}.dark\:text-sky-400{color:var(--color-sky-400)}.dark\:text-slate-100{color:var(--color-slate-100)}.dark\:text-slate-300{color:var(--color-slate-300)}.dark\:text-slate-400{color:var(--color-slate-400)}.dark\:text-yellow-100{color:var(--color-yellow-100)}.dark\:placeholder-gray-400::placeholder{color:var(--color-gray-400)}.dark\:accent-sky-400{accent-color:var(--color-sky-400)}@media (hover:hover){.dark\:hover\:bg-green-600:hover{background-color:var(--color-green-600)}.dark\:hover\:bg-red-500:hover{background-color:var(--color-red-500)}.dark\:hover\:bg-red-600:hover{background-color:var(--color-red-600)}.dark\:hover\:bg-sky-500:hover{background-color:var(--color-sky-500)}.dark\:hover\:bg-slate-600:hover{background-color:var(--color-slate-600)}.dark\:hover\:bg-slate-700:hover{background-color:var(--color-slate-700)}.dark\:hover\:text-slate-200:hover{color:var(--color-slate-200)}}.dark\:focus\:ring-red-700:focus{--tw-ring-color:var(--color-red-700)}.dark\:focus\:ring-slate-600:focus{--tw-ring-color:var(--color-slate-600)}}@media (min-width:48rem){@media (prefers-color-scheme:dark){.md\:dark\:bg-transparent{background-color:#0000}}}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}} \ No newline at end of file diff --git a/src/main/resources/static/js/locale-switcher.js b/src/main/resources/static/js/locale-switcher.js new file mode 100644 index 0000000..0edcd65 --- /dev/null +++ b/src/main/resources/static/js/locale-switcher.js @@ -0,0 +1,27 @@ +document.addEventListener("DOMContentLoaded", () => { + const updateLocale = (lang) => { + if (!lang) return; + const url = new URL(window.location.href); + url.searchParams.set("lang", lang); + window.location.assign(url.toString()); + }; + + const selects = document.querySelectorAll(".locale-switch-select[data-locale-select]"); + selects.forEach((select) => { + select.addEventListener("change", () => { + updateLocale(select.value); + }); + }); + + const links = document.querySelectorAll(".locale-switch-link[data-lang]"); + if (!links.length) return; + + links.forEach((link) => { + link.addEventListener("click", (event) => { + event.preventDefault(); + updateLocale(link.dataset.lang); + }); + }); +}); + + diff --git a/src/main/resources/static/js/settings.js b/src/main/resources/static/js/settings.js index d19776c..287461b 100644 --- a/src/main/resources/static/js/settings.js +++ b/src/main/resources/static/js/settings.js @@ -410,7 +410,10 @@ async function sendNotificationTest(target, buttonId, statusId) { const status = document.getElementById(statusId); if (!button || !status) return; - status.textContent = "Testing…"; + const testingText = button.dataset.testingText || "Testing…"; + const errorText = button.dataset.errorText || "Request failed. See logs."; + + status.textContent = testingText; status.className = "text-slate-600 dark:text-slate-300"; button.disabled = true; @@ -431,7 +434,7 @@ async function sendNotificationTest(target, buttonId, statusId) { ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"; } catch (e) { - status.textContent = "Request failed. See logs."; + status.textContent = errorText; status.className = "text-red-600 dark:text-red-400"; } finally { button.disabled = false; diff --git a/src/main/resources/templates/admin-password.html b/src/main/resources/templates/admin-password.html index cf0019f..c1a7f43 100644 --- a/src/main/resources/templates/admin-password.html +++ b/src/main/resources/templates/admin-password.html @@ -2,7 +2,7 @@ - Enter Admin Password + Enter Admin Password @@ -37,13 +37,13 @@ target="_blank" th:if="${!#strings.equalsIgnoreCase(#strings.trim(appName), 'QuickDrop')}" > - Powered by QuickDrop + Powered by QuickDrop +
+ + +

+ diff --git a/src/main/resources/templates/app-password.html b/src/main/resources/templates/app-password.html index 0521956..99dbe02 100644 --- a/src/main/resources/templates/app-password.html +++ b/src/main/resources/templates/app-password.html @@ -2,7 +2,7 @@ - Password Required + Password Required @@ -16,7 +16,19 @@

-
+
+
+ + +
@@ -82,5 +96,6 @@
+ diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 2ce3220..72166c2 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -2,7 +2,7 @@ - QuickDrop Admin + QuickDrop Admin @@ -39,13 +39,13 @@ target="_blank" th:if="${!#strings.equalsIgnoreCase(#strings.trim(appName), 'QuickDrop')}" > - Powered by QuickDrop + Powered by QuickDrop
+
+ + +
@@ -211,13 +225,13 @@ class="bg-gray-100 dark:bg-gray-800 divide-x divide-slate-200 dark:divide-slate-600" > - Name + Name - Upload Date/Last Renewed + Upload Date/Last Renewed - Size + Size - Downloads + Downloads @@ -246,18 +260,21 @@ View History Download @@ -276,7 +293,7 @@ class="inline-flex rounded-lg bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 text-white font-medium px-4 py-1.5 text-sm transition-colors active:scale-95 focus:outline-none focus:ring-2 focus:ring-red-500" type="submit" > - Delete + Delete @@ -306,7 +323,7 @@ type="checkbox" value="true" /> - Keep indefinitely + Keep indefinitely @@ -331,7 +348,7 @@ type="checkbox" value="true" /> - Hidden + Hidden @@ -344,7 +361,7 @@ class="text-center text-gray-600 dark:text-gray-300" th:if="${filesPage.totalElements == 0}" > -

No files found.

+

No files found.

PreviousPrevious NextNext
Per pagePer page + + + + +
+
+ + +
@@ -201,5 +215,6 @@ } }); + diff --git a/src/main/resources/templates/file-share-view.html b/src/main/resources/templates/file-share-view.html index 2f5fbc2..5d9de45 100644 --- a/src/main/resources/templates/file-share-view.html +++ b/src/main/resources/templates/file-share-view.html @@ -2,7 +2,7 @@ - Shared File View + Shared File View @@ -37,13 +37,13 @@ target="_blank" th:if="${!#strings.equalsIgnoreCase(#strings.trim(appName), 'QuickDrop')}" > - Powered by QuickDrop + Powered by QuickDrop +
+ + +
@@ -285,71 +302,46 @@ class="rounded-lg bg-sky-500 hover:bg-sky-600 dark:bg-sky-400 dark:hover:bg-sky-500 text-white font-medium px-4 py-2 transition-colors active:scale-95" th:href="@{/file/paste/edit/{uuid}(uuid=${file.uuid})}" th:if="${file.paste}" + th:text="#{page.fileView.editPaste}" >Edit Paste - -
-
- Preview - -
-
-
-
-
+ + diff --git a/src/main/resources/templates/invalid-share-link.html b/src/main/resources/templates/invalid-share-link.html index bab6837..687b4ce 100644 --- a/src/main/resources/templates/invalid-share-link.html +++ b/src/main/resources/templates/invalid-share-link.html @@ -2,7 +2,7 @@ - Share Link Invalid + Share Link Invalid @@ -37,13 +37,13 @@ target="_blank" th:if="${!#strings.equalsIgnoreCase(#strings.trim(appName), 'QuickDrop')}" > - Powered by QuickDrop + Powered by QuickDrop +
+ + +
+
+ + +
@@ -158,59 +174,60 @@ class="text-center text-gray-600 dark:text-gray-300 my-5" th:if="${filesPage.totalElements == 0}" > -

No files have been uploaded yet.

+

No files have been uploaded yet.

- Start by + Start by uploading a file.

-
-
-
-

- File Name -

-
-

-
- - -
-

-
-
- Go to file page +
+
+

+ File Name +

+
+

+
+ +
+

+
+
@@ -224,19 +241,21 @@ class="px-4 py-2 rounded-lg bg-slate-200 dark:bg-slate-700 text-sm font-semibold transition disabled:opacity-50 disabled:pointer-events-none" th:classappend="${filesPage.first} ? ' opacity-50 pointer-events-none' : ' hover:bg-slate-300 dark:hover:bg-slate-600'" th:href="@{/file/list(page=${filesPage.first ? filesPage.number : filesPage.number - 1}, size=${pageSize}, query=${query})}" + th:text="#{pagination.previous}" >Previous Next
- + + + + +
Edit Download
@@ -239,12 +257,15 @@
- + - Rendered markdown is enabled for this paste. + Rendered markdown is enabled for this paste.
-            

Share

+

Share

@@ -280,6 +302,7 @@ class="w-full rounded-lg border border-slate-300 dark:border-slate-600 px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700" id="copyShareButton" type="button" + th:text="#{page.pasteView.copyShareLink}" > Copy share link @@ -343,17 +366,22 @@ } else if (isMarkdownPaste && !markdownRuntimeAvailable) { markdownButton.classList.add("hidden"); } + const copySuccessText = /*[[#{notification.copied}]]*/ "Copied"; + const copyFailedText = /*[[#{notification.failed}]]*/ "Failed"; + const copyButtonText = /*[[#{page.pasteView.copyText}]]*/ "Copy text"; + const copyShareButtonText = /*[[#{page.pasteView.copyShareLink}]]*/ "Copy share link"; + const generateLinkFailedText = /*[[#{page.pasteView.generateLinkFailed}]]*/ "Unable to generate share link"; copyTextButton.addEventListener("click", async () => { try { await navigator.clipboard.writeText(rawText); - copyTextButton.textContent = "Copied"; + copyTextButton.textContent = copySuccessText; setTimeout(() => { - copyTextButton.textContent = "Copy text"; + copyTextButton.textContent = copyButtonText; }, 1200); } catch (_) { - copyTextButton.textContent = "Failed"; + copyTextButton.textContent = copyFailedText; setTimeout(() => { - copyTextButton.textContent = "Copy text"; + copyTextButton.textContent = copyButtonText; }, 1200); } }); @@ -383,10 +411,10 @@ ? new URL(sharePath, window.location.origin).toString() : ""; } else { - shareLinkInput.value = data?.message || "Unable to generate share link"; + shareLinkInput.value = data?.message || generateLinkFailedText; } } catch (_) { - shareLinkInput.value = "Unable to generate share link"; + shareLinkInput.value = generateLinkFailedText; } }); } @@ -398,14 +426,14 @@ } try { await navigator.clipboard.writeText(shareLinkInput.value); - copyShareButton.textContent = "Copied"; + copyShareButton.textContent = copySuccessText; setTimeout(() => { - copyShareButton.textContent = "Copy share link"; + copyShareButton.textContent = copyShareButtonText; }, 1200); } catch (_) { - copyShareButton.textContent = "Failed"; + copyShareButton.textContent = copyFailedText; setTimeout(() => { - copyShareButton.textContent = "Copy share link"; + copyShareButton.textContent = copyShareButtonText; }, 1200); } }); @@ -417,6 +445,7 @@ showRaw(); } + diff --git a/src/main/resources/templates/pastebin.html b/src/main/resources/templates/pastebin.html index a0fa503..9748266 100644 --- a/src/main/resources/templates/pastebin.html +++ b/src/main/resources/templates/pastebin.html @@ -2,7 +2,7 @@ - Pastebin + Pastebin @@ -361,7 +361,7 @@ +
+ + +
+
+ + +
@@ -113,7 +125,7 @@

- Admin Settings + Admin Settings

@@ -131,29 +143,30 @@ class="bg-white dark:bg-slate-800 p-6 md:p-8 rounded-2xl shadow-lg" >
-

Branding

+

Branding

- + Application name -

+

Shown in the navbar, page titles, and share views. Leave empty to reset to QuickDrop.

- + Logo / favicon
- Browse + Browse - Reset to default logo + Reset to default logo
-

+

Upload a square image for best results. Leave empty to keep the current logo or reset to restore the QuickDrop icon.

@@ -206,15 +220,15 @@ class="bg-white dark:bg-slate-800 p-6 md:p-8 rounded-2xl shadow-lg" >
-

File Settings

+

File Settings

- + Max File Size (MB) - + Max File Lifetime (days) - + File Storage Path - Disable file previewsDisable file previews
Hide inline previews for images/text on the file + th:text="#{page.settings.file.disablePreview.help}"> + Hide inline previews for images/text on the file page. @@ -279,6 +295,7 @@ -

+

Above this size users must click "Load preview".

@@ -299,7 +317,9 @@
-
Keep Indefinitely
+
Keep + Indefinitely +
+ diff --git a/src/main/resources/templates/welcome.html b/src/main/resources/templates/welcome.html index 54c2f1c..69a61df 100644 --- a/src/main/resources/templates/welcome.html +++ b/src/main/resources/templates/welcome.html @@ -2,7 +2,7 @@ - Welcome to QuickDrop + Welcome to QuickDrop @@ -16,15 +16,29 @@
+
+
+ + +
+

- Welcome to QuickDrop + Welcome to QuickDrop

- + Thank you for setting up QuickDrop! - Please set an admin password for the dashboard. + Please set an admin password for the dashboard.

@@ -43,12 +57,13 @@ type="hidden" />
@@ -100,5 +116,6 @@ } }); +