diff --git a/CHEATSHEET_TOOLS_ADDED.md b/CHEATSHEET_TOOLS_ADDED.md new file mode 100644 index 00000000..9c8bc292 --- /dev/null +++ b/CHEATSHEET_TOOLS_ADDED.md @@ -0,0 +1,182 @@ +# Cheatsheet Tools - DevHints.io Inspired + +## Overview +Added 8 comprehensive cheatsheet tools to DevTools, inspired by devhints.io. These tools provide quick references for popular technologies and languages with searchable, organized content. + +## New Cheatsheet Tools + +### 1. **JavaScript Cheatsheet** (`/javascript-cheatsheet`) +Quick reference for essential JavaScript operations: +- String methods (charAt, slice, substring, split, replace, etc.) +- Array methods (push, pop, map, filter, reduce, forEach, etc.) +- Object methods (Object.keys, Object.values, Object.entries, Object.assign, etc.) +- Fully searchable with organized sections +- Copy-to-clipboard for all code examples + +### 2. **React Cheatsheet** (`/react-cheatsheet`) +Essential React reference guide: +- React Hooks (useState, useEffect, useContext, useReducer, useMemo, useCallback, etc.) +- Component patterns and lifecycle +- Event handling (onClick, onChange, onSubmit, etc.) +- Props, PropTypes, and default props +- Searchable library of React patterns + +### 3. **CSS Cheatsheet** (`/css-cheatsheet`) +Layout and styling quick reference: +- Flexbox properties and values +- CSS Grid layout +- Positioning (position, z-index, transform) +- Box model (margin, padding, border, box-sizing) +- Searchable CSS properties with examples + +### 4. **Git Cheatsheet** (`/git-cheatsheet`) +Version control commands reference: +- Basic commands (init, clone, status, add, commit, push, pull) +- Branching operations (branch, checkout, merge, switch) +- Viewing history (log, show, diff) +- Undoing changes (restore, revert, reset) +- Command descriptions and copy functionality + +### 5. **SQL Cheatsheet** (`/sql-cheatsheet`) +Database query reference: +- SELECT queries (WHERE, ORDER BY, GROUP BY, HAVING, DISTINCT) +- JOINs (INNER, LEFT, RIGHT, FULL OUTER, CROSS) +- Aggregate functions (COUNT, SUM, AVG, MAX, MIN) +- INSERT, UPDATE, DELETE operations +- Real SQL examples for common patterns + +### 6. **Bash Cheatsheet** (`/bash-cheatsheet`) +Linux/Unix terminal commands: +- File operations (ls, cd, mkdir, rm, cp, mv, touch, cat) +- File search & filtering (find, grep, which, whereis) +- System information (uname, df, du, ps, top, whoami, id) +- Text processing (sed, awk, sort, uniq, cut, wc) +- Descriptions and instant copy capability + +### 7. **Docker Cheatsheet** (`/docker-cheatsheet`) +Container management reference: +- Image operations (build, images, rmi, pull, push, tag) +- Container operations (run, ps, stop, start, rm, exec) +- Logs and debugging (logs, exec, inspect) +- Docker Compose commands +- Port mapping, environment variables, and more + +### 8. **Vue Cheatsheet** (`/vue-cheatsheet`) +Vue 3 framework reference: +- Template syntax (interpolation, directives, v-if, v-for, v-on, v-model, v-class, v-style) +- Composition API (ref, reactive, computed, watch, lifecycle hooks) +- Component basics (props, emits, slots) +- Lifecycle hooks (onMounted, onBeforeUnmount, etc.) + +## Features Across All Cheatsheets + +✅ **Searchable** - Filter content in real-time as you type +✅ **Organized** - Content grouped into logical sections with accordion layout +✅ **Copy-Friendly** - One-click copy for all code examples +✅ **Responsive** - Works perfectly on desktop, tablet, and mobile +✅ **Dark Mode** - Full dark mode support +✅ **Accessible** - Proper semantic HTML and ARIA attributes +✅ **Keyboard Friendly** - Navigate and interact with keyboard +✅ **Keywords** - Proper SEO keywords for search functionality + +## Technical Implementation + +### Architecture +- **Component Pattern**: Vue 3 with ` + + + + +

Enclosed - Send Private and Secure Notes

+

Enclosed is a secure platform for sending private notes. All notes are end-to-end encrypted with zero knowledge on the server side.

+ + +
+ + + + diff --git a/enclosed/packages/app-client/package.json b/enclosed/packages/app-client/package.json new file mode 100644 index 00000000..1e751552 --- /dev/null +++ b/enclosed/packages/app-client/package.json @@ -0,0 +1,63 @@ +{ + "name": "@khulnasoft.com/enclosed-client", + "type": "module", + "version": "1.14.1", + "packageManager": "pnpm@9.15.0", + "description": "Enclosed frontend client", + "author": "Md Sulaiman (https://khulnasoft.com)", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/khulnasoft/devtools" + }, + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "test": "pnpm run test:unit", + "test:unit": "vitest run", + "test:unit:watch": "vitest watch", + "test:e2e": "playwright test", + "typecheck": "tsc --noEmit", + "script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts" + }, + "dependencies": { + "@corentinth/chisels": "^1.0.2", + "@khulnasoft.com/enclosed-lib": "workspace:*", + "@kobalte/core": "^0.13.4", + "@solid-primitives/i18n": "^2.1.1", + "@solid-primitives/storage": "^4.2.1", + "@solidjs/router": "^0.14.3", + "@unocss/reset": "^0.64.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "jszip": "^3.10.1", + "lodash-es": "^4.17.21", + "solid-js": "^1.8.11", + "solid-sonner": "^0.2.8", + "tailwind-merge": "^2.5.2", + "unocss-preset-animations": "^1.1.0", + "uqr": "^0.1.2" + }, + "devDependencies": { + "@antfu/eslint-config": "catalog:", + "@iconify-json/tabler": "^1.1.120", + "@playwright/test": "^1.46.1", + "@types/lodash-es": "^4.17.12", + "@types/node": "catalog:", + "eslint": "catalog:", + "jsdom": "^25.0.0", + "tsx": "^4.19.1", + "typescript": "catalog:", + "unocss": "^0.64.0", + "vite": "^5.0.11", + "vite-plugin-solid": "^2.8.2", + "vitest": "catalog:" + } +} diff --git a/enclosed/packages/app-client/playwright.config.ts b/enclosed/packages/app-client/playwright.config.ts new file mode 100644 index 00000000..184d60d1 --- /dev/null +++ b/enclosed/packages/app-client/playwright.config.ts @@ -0,0 +1,67 @@ +import process from 'node:process'; +import { defineConfig, devices } from '@playwright/test'; + +const port = process.env.PORT ?? '3999'; +const baseURL = process.env.BASE_URL ?? `http://localhost:${port}/`; +const isCI = Boolean(process.env.CI); +const startPreviewServer = Boolean(process.env.USE_PREVIEW_SERVER); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e-tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: isCI, + /* Retry on CI only */ + retries: isCI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: isCI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + testIdAttribute: 'data-test-id', + locale: 'en-GB', + timezoneId: 'Europe/Paris', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + + ...(startPreviewServer + ? { + webServer: { + command: 'cd dist-app && node index.cjs', + reuseExistingServer: true, + env: { + PORT: port, + }, + }, + } + : {}), + +}); diff --git a/enclosed/packages/app-client/public/android-chrome-192x192.png b/enclosed/packages/app-client/public/android-chrome-192x192.png new file mode 100644 index 00000000..cd10d726 Binary files /dev/null and b/enclosed/packages/app-client/public/android-chrome-192x192.png differ diff --git a/enclosed/packages/app-client/public/android-chrome-512x512.png b/enclosed/packages/app-client/public/android-chrome-512x512.png new file mode 100644 index 00000000..ea5674b8 Binary files /dev/null and b/enclosed/packages/app-client/public/android-chrome-512x512.png differ diff --git a/enclosed/packages/app-client/public/apple-touch-icon.png b/enclosed/packages/app-client/public/apple-touch-icon.png new file mode 100644 index 00000000..bcb28fbf Binary files /dev/null and b/enclosed/packages/app-client/public/apple-touch-icon.png differ diff --git a/enclosed/packages/app-client/public/favicon-16x16.png b/enclosed/packages/app-client/public/favicon-16x16.png new file mode 100644 index 00000000..e7dccdb6 Binary files /dev/null and b/enclosed/packages/app-client/public/favicon-16x16.png differ diff --git a/enclosed/packages/app-client/public/favicon-32x32.png b/enclosed/packages/app-client/public/favicon-32x32.png new file mode 100644 index 00000000..84c4f227 Binary files /dev/null and b/enclosed/packages/app-client/public/favicon-32x32.png differ diff --git a/enclosed/packages/app-client/public/favicon.ico b/enclosed/packages/app-client/public/favicon.ico new file mode 100644 index 00000000..ff93a47c Binary files /dev/null and b/enclosed/packages/app-client/public/favicon.ico differ diff --git a/enclosed/packages/app-client/public/humans.txt b/enclosed/packages/app-client/public/humans.txt new file mode 100644 index 00000000..bf45eb5c --- /dev/null +++ b/enclosed/packages/app-client/public/humans.txt @@ -0,0 +1,4 @@ +/* TEAM */ +Developer: Md Sulaiman +Site: https://khulnasoft.com +Twitter: @cthmsst \ No newline at end of file diff --git a/enclosed/packages/app-client/public/og-image.png b/enclosed/packages/app-client/public/og-image.png new file mode 100644 index 00000000..0400fd1f Binary files /dev/null and b/enclosed/packages/app-client/public/og-image.png differ diff --git a/enclosed/packages/app-client/public/robots.txt b/enclosed/packages/app-client/public/robots.txt new file mode 100644 index 00000000..4b9fa7d1 --- /dev/null +++ b/enclosed/packages/app-client/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Disallow: / +Allow: /$ +Allow: /og-image.png diff --git a/enclosed/packages/app-client/public/site.webmanifest b/enclosed/packages/app-client/public/site.webmanifest new file mode 100644 index 00000000..45dc8a20 --- /dev/null +++ b/enclosed/packages/app-client/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/enclosed/packages/app-client/src/app.css b/enclosed/packages/app-client/src/app.css new file mode 100644 index 00000000..74a6e6f9 --- /dev/null +++ b/enclosed/packages/app-client/src/app.css @@ -0,0 +1,75 @@ +:root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --warning: 31 98% 50%; + --warning-foreground: 0 0% 98%; + + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + + --radius: 0.5rem; +} + +[data-kb-theme="dark"] { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --warning: 31 98% 50%; + --warning-foreground: 0 0% 98%; + + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; +} + + +* { + @apply border-border; +} +body { + @apply bg-background text-foreground; +} \ No newline at end of file diff --git a/enclosed/packages/app-client/src/assets/favicon.ico b/enclosed/packages/app-client/src/assets/favicon.ico new file mode 100644 index 00000000..b836b2bc Binary files /dev/null and b/enclosed/packages/app-client/src/assets/favicon.ico differ diff --git a/enclosed/packages/app-client/src/index.tsx b/enclosed/packages/app-client/src/index.tsx new file mode 100644 index 00000000..d391cd83 --- /dev/null +++ b/enclosed/packages/app-client/src/index.tsx @@ -0,0 +1,44 @@ +/* @refresh reload */ + +import { ColorModeProvider, ColorModeScript, createLocalStorageManager } from '@kobalte/core/color-mode'; +import { Router } from '@solidjs/router'; +import { render, Suspense } from 'solid-js/web'; +import { I18nProvider } from './modules/i18n/i18n.provider'; +import { NoteContextProvider } from './modules/notes/notes.context'; +import { Toaster } from './modules/ui/components/sonner'; +import { routes } from './routes'; +import '@unocss/reset/tailwind.css'; +import 'virtual:uno.css'; +import './app.css'; + +render( + () => { + const initialColorMode = 'system'; + const colorModeStorageKey = 'enclosed_color_mode'; + const localStorageManager = createLocalStorageManager(colorModeStorageKey); + + return ( + ( + + + + + +
{props.children}
+ + +
+
+
+
+ )} + /> + ); + }, + document.getElementById('root')!, +); diff --git a/enclosed/packages/app-client/src/locales/README.md b/enclosed/packages/app-client/src/locales/README.md new file mode 100644 index 00000000..11d5a9de --- /dev/null +++ b/enclosed/packages/app-client/src/locales/README.md @@ -0,0 +1,31 @@ +# Contributing to Translations + +We welcome contributions to improve and expand the app's internationalization (i18n) support. Below are the guidelines for adding a new language or updating an existing translation. + +## Adding a New Language + +1. **Create a Language File**: To add a new language, create a JSON file named with the appropriate [ISO language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (e.g., `fr.json` for French) in the [`packages/app-client/src/locales`](./) directory. + +2. **Use the Reference File**: Refer to the [`en.json`](./en.json) file, which contains all keys used in the app. Use it as a base to ensure consistency when creating your new language file. And act as a fallback if a key is missing in the new language file. + +3. **Update the Locale List**: After adding the new language file, include the language code in the `locales` array found in the [`locales.ts`](./locales.ts) file. + +4. **[Optional] Check for Missing Keys**: You can verify that all translation keys are included by running the following command in the `app-client` package: + + ```bash + pnpm script:get-missing-i18n-keys + ``` + +5. **Submit a Pull Request**: Once you've added the file and updated `locales.ts`, create a pull request (PR) with your changes. Ensure that your PR is clearly titled with the language being added (e.g., "Add French translations"). + +## Updating an Existing Language + +To improve or correct an existing language: + +1. **Edit the Language File**: Make updates directly to the relevant JSON file in the [`packages/app-client/src/locales`](./) directory. + +2. **Submit a Pull Request**: After making your changes, submit a pull request. Clearly describe the updates in the PR. + +--- + +Thank you for helping us make the app accessible to a broader audience! diff --git a/enclosed/packages/app-client/src/locales/ar.json b/enclosed/packages/app-client/src/locales/ar.json new file mode 100644 index 00000000..8f50fa6e --- /dev/null +++ b/enclosed/packages/app-client/src/locales/ar.json @@ -0,0 +1,151 @@ +{ + "app": { + "title": "Enclosed", + "description": "إرسال ملاحظات خاصة وآمنة" + }, + "insecureContextWarning": { + "description": "اتصالك غير آمن، يجب تقديم التطبيق عبر HTTPS ليعمل بشكل صحيح. لن تتمكن من إنشاء أو عرض الملاحظات.", + "learn-more": "تعرف على المزيد" + }, + "navbar": { + "new-note": "ملاحظة جديدة", + "github": "GitHub", + "language": "اللغة", + "theme": { + "theme": "الشكل", + "light-mode": "الوضع الفاتح", + "dark-mode": "الوضع الداكن", + "system-mode": "النظام" + }, + "settings": { + "documentation": "التوثيق", + "cli": "مغلق CLI", + "support": "الدعم", + "report-bug": "الإبلاغ عن خطأ", + "logout": "الخروج", + "contribute-to-i18n": "المساهمة في الترجمة" + } + }, + "footer": { + "crafted-by": "من صنع", + "source-code": "الكود المصدري متاح على", + "github": "GitHub", + "version": "الإصدار" + }, + "login": { + "title": "تسجيل الدخول", + "description": "هذه نسخة خاصة . أدخل بيانات اعتمادك لتتمكن من إنشاء الملاحظات.", + "email": "البريد الإلكتروني", + "password": "كلمة المرور", + "submit": "تسجيل الدخول", + "errors": { + "invalid-credentials": "البريد الإلكتروني أو كلمة المرور غير صحيحة.", + "unknown": "حدث خطأ غير معروف. يرجى المحاولة مرة أخرى لاحقًا." + }, + "footer": [ + "ليس لديك حساب؟", + "اتصل بمالك النسخة." + ] + }, + "create": { + "errors": { + "empty-note": "يرجى إدخال محتوى الملاحظة أو إرفاق ملف.", + "rate-limit": "لقد تجاوزت الحد الأقصى لإنشاء الملاحظات. يرجى المحاولة مرة أخرى لاحقًا.", + "too-large": "محتوى الملاحظة والمرفقات كبيرة جدًا. يرجى تقليل الحجم والمحاولة مرة أخرى.", + "unauthorized": "أنت غير مصرح لك بإنشاء الملاحظات. يرجى تسجيل الدخول والمحاولة مرة أخرى.", + "api-not-found": "لم يتم العثور على نقطة نهاية API. قد لا يكون الخادم مكونًا بشكل صحيح.", + "unknown": "حدث خطأ أثناء إنشاء الملاحظة، يرجى المحاولة مرة أخرى." + }, + "share": { + "button": "مشاركة الملاحظة", + "title": "ملاحظة مشتركة", + "description": "إليك ملاحظة تمت مشاركتها معك." + }, + "settings": { + "placeholder": "اكتب ملاحظتك هنا...", + "password": { + "label": "كلمة مرور الملاحظة", + "placeholder": "كلمة المرور..." + }, + "expiration": "مدة الصلاحية", + "delays": { + "1h": "ساعة ", + "1d": "يوم ", + "1w": "أسبوع ", + "1m": "شهر " + }, + "delete-after-reading": { + "label": "حذف بعد القراءة", + "description": "حذف الملاحظة بعد القراءة" + }, + "attach-files": "إرفاق ملفات", + "drop-files": { + "title": "إسقاط الملفات هنا", + "description": "اسحب وأفلت الملفات هنا لإرفاقها بالملاحظة" + }, + "create": "إنشاء ملاحظة", + "creating": "جارٍ إنشاء الملاحظة..." + }, + "success": { + "title": "تم إنشاء الملاحظة بنجاح", + "description": "تم إنشاء ملاحظتك. يمكنك الآن مشاركتها باستخدام الرابط التالي.", + "with-deletion": "سيتم حذف الملاحظة بعد القراءة.", + "copy-link": "نسخ الرابط", + "copy-success": "تم نسخ الرابط" + } + }, + "view": { + "note-content": "محتوى الملاحظة", + "assets": { + "download": "تنزيل", + "download-all": "تنزيل جميع الملفات", + "heading-multiple": "{{ count }} ملفات مرفقة بهذه الملاحظة", + "heading-single": "ملف واحد مرفق بهذه الملاحظة" + }, + "request-password": { + "description": "هذه الملاحظة محمية بكلمة مرور. يرجى إدخال كلمة المرور لفتحها.", + "form": { + "label": "كلمة المرور", + "placeholder": "كلمة مرور الملاحظة...", + "unlock-button": "فتح الملاحظة", + "invalid": "كلمة المرور التي أدخلتها غير صحيحة أو عنوان URL للملاحظة غير صحيح." + } + }, + "warn-for-note-deletion": { + "title": "سيتم حذف هذه الملاحظة", + "description": "تم تكوين هذه الملاحظة ليتم حذفها بعد القراءة. بعد عرض الملاحظة، سيتم حذفها نهائيًا ولا يمكن استعادتها.", + "confirm": "أفهم، أرني الملاحظة" + }, + "error": { + "invalid-url": { + "title": "عنوان URL للملاحظة غير صالح", + "description": "عنوان URL للملاحظة غير صالح. يرجى التأكد من أنك تستخدم العنوان الصحيح." + }, + "rate-limit": { + "title": "تم تجاوز الحد الأقصى", + "description": "لقد تجاوزت الحد الأقصى لجلب الملاحظات. يرجى المحاولة مرة أخرى لاحقًا." + }, + "unauthorized": { + "title": "غير مصرح", + "description": "هذه الملاحظة خاصة. تحتاج إلى تسجيل الدخول لعرضها.", + "button": "تسجيل الدخول" + }, + "note-not-found": { + "title": "لم يتم العثور على الملاحظة", + "description": "هذه الملاحظة غير موجودة، أو انتهت صلاحيتها، أو تم حذفها." + }, + "fetch-error": { + "title": "حدث خطأ", + "description": "حدث خطأ أثناء جلب الملاحظة. يرجى المحاولة مرة أخرى لاحقًا." + }, + "decryption": { + "title": "حدث خطأ", + "description": "حدث خطأ أثناء فك تشفير الملاحظة. قد يكون العنوان غير صالح." + } + } + }, + "copy": { + "label": "نسخ إلى الحافظة", + "success": "تم النسخ!" + } +} diff --git a/enclosed/packages/app-client/src/locales/de.json b/enclosed/packages/app-client/src/locales/de.json new file mode 100644 index 00000000..07261829 --- /dev/null +++ b/enclosed/packages/app-client/src/locales/de.json @@ -0,0 +1,148 @@ +{ + "app": { + "title": "Enclosed", + "description": "Private und sichere Notizen versenden" + }, + "insecureContextWarning": { + "description": "Ihre Verbindung ist nicht sicher. Die App muss über HTTPS bereitgestellt werden, damit sie ordnungsgemäß funktioniert. Sie können keine Notizen erstellen oder anzeigen.", + "learn-more": "Mehr dazu erfahren" + }, + "navbar": { + "new-note": "Neue Notiz", + "github": "GitHub", + "language": "Sprache", + "theme": { + "theme": "Theme", + "light-mode": "Heller Modus", + "dark-mode": "Dunkler Modus", + "system-mode": "Systemeinstellungen" + }, + "settings": { + "documentation": "Dokumentation", + "cli": "Enclosed CLI", + "support": "Enclosed unterstützen", + "report-bug": "Einen Fehler melden", + "logout": "Abmelden", + "contribute-to-i18n": "Zu i18n beitragen" + } + }, + "footer": { + "crafted-by": "Geschaffen von", + "source-code": "Quellcode verfügbar auf", + "github": "GitHub", + "version": "Version" + }, + "login": { + "title": "An Enclosed anmelden", + "description": "Dies ist eine private Instanz von Enclosed. Geben Sie Ihre Anmeldedaten ein, um Notizen erstellen zu können.", + "email": "E-Mail", + "password": "Passwort", + "submit": "Login", + "errors": { + "invalid-credentials": "Ungültige E-Mail oder ungültiges Passwort.", + "unknown": "Ein unbekannter Fehler ist aufgetreten. Bitte versuchen Sie es später noch einmal." + }, + "footer": [ + "Sie haben keinen Account?", + "Kontaktieren Sie den Besitzer der Instanz." + ] + }, + "create": { + "errors": { + "empty-note": "Bitte geben Sie eine Notiz ein oder hängen Sie eine Datei an.", + "rate-limit": "Sie haben das Limit für die Erstellung von Notizen überschritten. Bitte versuchen Sie es später erneut.", + "too-large": "Der Inhalt der Notiz und die Anhänge sind zu groß. Bitte verkleinern Sie die Größe und versuchen Sie es erneut.", + "unauthorized": "Sie sind nicht berechtigt, Notizen zu erstellen. Bitte melden Sie sich an und versuchen Sie es erneut.", + "unknown": "Beim Erstellen der Notiz ist ein Fehler aufgetreten, bitte versuchen Sie es erneut." + }, + "share": { + "button": "Notiz teilen", + "title": "Geteilte Notiz", + "description": "Hier ist eine Notiz, die mit Ihnen geteilt wurde." + }, + "settings": { + "placeholder": "Schreiben Sie Ihre Notiz hier...", + "password": { + "label": "Notiz Passwort", + "placeholder": "Passwort..." + }, + "expiration": "Verfallszeit", + "delays": { + "1h": "1 Stunde", + "1d": "1 Tag", + "1w": "1 Woche", + "1m": "1 Monat" + }, + "delete-after-reading": { + "label": "Nach dem Lesen löschen", + "description": "Die Notiz wird nach dem Lesen automatisch gelöscht." + }, + "attach-files": "Dateien anhängen", + "drop-files": { + "title": "Dateien hier ablegen", + "description": "Ziehen Sie Dateien hierher, um sie an die Notiz anzuhängen" + }, + "create": "Notiz erstellen", + "creating": "Notiz wird erstellt..." + }, + "success": { + "title": "Notiz erfolgreich erstellt", + "description": "Ihre Notiz wurde erstellt. Sie können sie nun über den folgenden Link teilen.", + "with-deletion": "Die Notiz wird nach dem Lesen automatisch gelöscht.", + "copy-link": "Link kopieren", + "copy-success": "Der Link wurde kopiert" + } + }, + "view": { + "note-content": "Inhalt der Notiz", + "assets": { + "download": "Herunterladen", + "download-all": "Alle Dateien herunterladen" + }, + "request-password": { + "description": "Diese Notiz ist passwortgeschützt. Bitte geben Sie das Passwort ein, um sie zu entsperren.", + "form": { + "label": "Passwort", + "placeholder": "Notiz Passwort...", + "unlock-button": "Notiz entsperren", + "invalid": "Das eingegebene Passwort ist ungültig oder die URL der Notiz ist falsch." + } + }, + "warn-for-note-deletion": { + "title": "Diese Notiz wird gelöscht", + "description": "Diese Notiz wurde so konfiguriert, dass sie nach dem Lesen automatisch gelöscht wird. Nach der Anzeige der Notiz wird sie endgültig gelöscht und kann nicht wiederhergestellt werden.", + "confirm": "Verstanden, zeig mir die Notiz" + }, + "error": { + "invalid-url": { + "title": "Ungültige Notiz-URL", + "description": "Diese Notiz-URL ist ungültig. Bitte stellen Sie sicher, dass Sie die richtige URL verwenden." + }, + "rate-limit": { + "title": "Tarifgrenze überschritten", + "description": "Sie haben das Limit für den Abruf von Notizen überschritten. Bitte versuchen Sie es später erneut." + }, + "unauthorized": { + "title": "Unberechtigt", + "description": "Diese Notiz ist privat. Sie müssen eingeloggt sein, um sie zu sehen.", + "button": "Anmelden" + }, + "note-not-found": { + "title": "Notiz nicht gefunden", + "description": "Diese Notiz existiert nicht, ist abgelaufen oder wurde gelöscht." + }, + "fetch-error": { + "title": "Es ist ein Fehler aufgetreten", + "description": "Beim Abrufen der Notiz ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal." + }, + "decryption": { + "title": "Es ist ein Fehler aufgetreten", + "description": "Beim Entschlüsseln der Notiz ist ein Fehler aufgetreten. Die URL ist möglicherweise ungültig." + } + } + }, + "copy": { + "label": "In die Zwischenablage kopieren", + "success": "Kopiert!" + } +} diff --git a/enclosed/packages/app-client/src/locales/en.json b/enclosed/packages/app-client/src/locales/en.json new file mode 100644 index 00000000..de370e20 --- /dev/null +++ b/enclosed/packages/app-client/src/locales/en.json @@ -0,0 +1,171 @@ +{ + "app": { + "title": "Enclosed", + "description": "Send private and secure notes" + }, + "insecureContextWarning": { + "description": "Your connection is not secure, the app must be served over HTTPS to work properly. You won't be able to create or view notes.", + "learn-more": "Learn more" + }, + "navbar": { + "new-note": "New note", + "github": "GitHub", + "language": "Language", + "github-repository": "GitHub repository", + "documentation": "Documentation", + "change-theme": "Change theme", + "menu-icon": "Menu icon", + "change-language": "Change language", + "theme": { + "theme": "Theme", + "light-mode": "Light mode", + "dark-mode": "Dark mode", + "system-mode": "System" + }, + "settings": { + "documentation": "Documentation", + "cli": "Enclosed CLI", + "support": "Support Enclosed", + "report-bug": "Report a bug", + "logout": "Logout", + "contribute-to-i18n": "Contribute to i18n" + } + }, + "footer": { + "crafted-by": "Crafted by", + "source-code": "Source code available on", + "github": "GitHub", + "version": "Version" + }, + "login": { + "title": "Login to Enclosed", + "description": "This is a private instance of Enclosed. Enter your credentials to be able to create notes.", + "email": "Email", + "password": "Password", + "submit": "Login", + "errors": { + "invalid-credentials": "Invalid email or password.", + "unknown": "An unknown error occurred. Please try again later." + }, + "footer": [ + "Don't have an account?", + "Contact the owner of the instance." + ] + }, + "create": { + "errors": { + "empty-note": "Please enter a note content or attach a file.", + "rate-limit": "You have exceeded the rate limit for creating notes. Please try again later.", + "too-large": "The note content and attachments are too large. Please reduce the size and try again.", + "unauthorized": "You are not authorized to create notes. Please login and try again.", + "api-not-found": "The API endpoint is not found. The server may not be configured correctly.", + "unknown": "An error occurred while creating the note, please try again." + }, + "share": { + "button": "Share note", + "title": "Shared note", + "description": "Here is a note shared with you." + }, + "settings": { + "placeholder": "Type your note here...", + "password": { + "label": "Note password", + "placeholder": "Password...", + "hide-password": "Hide password", + "show-password": "Show password", + "generate-random-password": "Generate random password" + }, + "expiration": "Expiration delay", + "no-expiration": "The note never expires", + "delays": { + "1h": "1 hour", + "1d": "1 day", + "1w": "1 week", + "1m": "1 month" + }, + "delete-after-reading": { + "label": "Delete after reading", + "description": "Delete the note after reading" + }, + "attach-files": "Attach files", + "drop-files": { + "title": "Drop files here", + "description": "Drag and drop files here to attach them to the note" + }, + "create": "Create note", + "creating": "Creating note..." + }, + "success": { + "title": "Note created successfully", + "description": "Your note has been created. You can now share it using the following link.", + "with-deletion": "The note will be deleted after reading.", + "copy-link": "Copy link", + "copy-success": "Link copied" + }, + "qr-code": { + "title": "Share this note on mobile", + "description": "You can scan this QR code to view the note on your mobile device. You can also export the QR code image to share it with others.", + "export": "Export QR code", + "download-svg": "Download as SVG", + "download-png": "Download as PNG", + "download-success": "QR code downloaded", + "copy-svg": "Copy SVG code", + "copy-success": "SVG copied to clipboard" + } + }, + "view": { + "loading": "Loading note...", + "note-content": "Note content", + "assets": { + "download": "Download", + "download-all": "Download all files", + "heading-multiple": "{{ count }} files attached to this note", + "heading-single": "1 file attached to this note" + }, + "request-password": { + "description": "This note is password protected. Please enter the password to unlock it.", + "form": { + "label": "Password", + "placeholder": "Note password...", + "unlock-button": "Unlock note", + "invalid": "The password you entered is invalid or the note URL is incorrect." + } + }, + "warn-for-note-deletion": { + "title": "This note will be deleted", + "description": "This note has been configured to be deleted after reading. After displaying the note, it will be permanently deleted and cannot be recovered.", + "confirm": "I understand, show me the note" + }, + "error": { + "invalid-url": { + "title": "Invalid note URL", + "description": "This note URL is invalid. Please make sure you are using the correct URL." + }, + "rate-limit": { + "title": "Rate limit exceeded", + "description": "You have exceeded the rate limit for fetching notes. Please try again later." + }, + "unauthorized": { + "title": "Unauthorized", + "description": "This note is private. You need to be logged in to view it.", + "button": "Log in" + }, + "note-not-found": { + "title": "Note not found", + "description": "This note does not exist, has expired, or has been deleted." + }, + "fetch-error": { + "title": "An error occurred", + "description": "An error occurred while fetching the note. Please try again later." + }, + "decryption": { + "title": "An error occurred", + "description": "An error occurred while decrypting the note. The url may be invalid." + } + } + }, + "copy": { + "label": "Copy to clipboard", + "success": "Copied!" + } +} diff --git a/enclosed/packages/app-client/src/locales/es.json b/enclosed/packages/app-client/src/locales/es.json new file mode 100644 index 00000000..3c00bc62 --- /dev/null +++ b/enclosed/packages/app-client/src/locales/es.json @@ -0,0 +1,141 @@ +{ + "app": { + "title": "Enclosed", + "description": "Enviar notas privadas y seguras" + }, + "insecureContextWarning": { + "description": "Su conexión no es segura, la aplicación debe ser servida a través de HTTPS para funcionar correctamente. No podrá crear ni ver notas.", + "learn-more": "Más información" + }, + "navbar": { + "new-note": "Nueva nota", + "github": "GitHub", + "theme": { + "light-mode": "Modo claro", + "dark-mode": "Modo oscuro", + "system-mode": "Sistema" + }, + "settings": { + "documentation": "Documentación", + "cli": "Enclosed CLI", + "support": "Soporte Enclosed", + "report-bug": "Reportar un error", + "logout": "Cerrar sesión", + "contribute-to-i18n": "Contribuir a i18n" + } + }, + "footer": { + "crafted-by": "Creado por", + "source-code": "Código fuente disponible en", + "github": "GitHub", + "version": "Versión" + }, + "login": { + "title": "Iniciar sesión en Enclosed", + "description": "Esta es una instancia privada de Enclosed. Introduzca sus credenciales para poder crear notas.", + "email": "Correo electrónico", + "password": "Contraseña", + "submit": "Iniciar sesión", + "errors": { + "invalid-credentials": "Correo electrónico o contraseña incorrectos.", + "unknown": "Ocurrió un error desconocido. Por favor, inténtelo de nuevo más tarde." + }, + "footer": [ + "¿No tienes una cuenta?", + "Contacta al propietario de la instancia." + ] + }, + "create": { + "errors": { + "empty-note": "Por favor, introduzca el contenido de la nota o adjunte un archivo.", + "rate-limit": "Ha superado el límite de creación de notas. Por favor, inténtelo de nuevo más tarde.", + "too-large": "El contenido de la nota y los archivos adjuntos son demasiado grandes. Reduzca el tamaño e inténtelo de nuevo.", + "unauthorized": "No está autorizado para crear notas. Por favor, inicie sesión e inténtelo de nuevo.", + "unknown": "Ocurrió un error al crear la nota, por favor inténtelo de nuevo." + }, + "share": { + "button": "Compartir nota", + "title": "Nota compartida", + "description": "Aquí tienes una nota compartida contigo." + }, + "settings": { + "placeholder": "Escriba su nota aquí...", + "password": { + "label": "Contraseña de la nota", + "placeholder": "Contraseña..." + }, + "expiration": "Plazo de expiración", + "delays": { + "1h": "1 hora", + "1d": "1 día", + "1w": "1 semana", + "1m": "1 mes" + }, + "delete-after-reading": { + "label": "Eliminar después de leer", + "description": "Eliminar la nota después de leerla" + }, + "attach-files": "Adjuntar archivos", + "drop-files": { + "title": "Suelte los archivos aquí", + "description": "Arrastre y suelte los archivos aquí para adjuntarlos a la nota" + }, + "create": "Crear nota", + "creating": "Creando nota..." + }, + "success": { + "title": "Nota creada con éxito", + "description": "Su nota ha sido creada. Ahora puede compartirla utilizando el siguiente enlace.", + "with-deletion": "La nota se eliminará después de ser leída.", + "copy-link": "Copiar enlace", + "copy-success": "Enlace copiado" + } + }, + "view": { + "note-content": "Contenido de la nota", + "assets": { + "download": "Descargar", + "download-all": "Descargar todos los archivos" + }, + "request-password": { + "description": "Esta nota está protegida con contraseña. Introduzca la contraseña para desbloquearla.", + "form": { + "label": "Contraseña", + "placeholder": "Contraseña de la nota...", + "unlock-button": "Desbloquear nota", + "invalid": "La contraseña que ingresó es inválida o la URL de la nota es incorrecta." + } + }, + "error": { + "invalid-url": { + "title": "URL de la nota inválido", + "description": "La URL de esta nota no es válida. Asegúrese de estar utilizando la URL correcta." + }, + "rate-limit": { + "title": "Límite excedido", + "description": "Ha superado el límite para recuperar notas. Por favor, inténtelo de nuevo más tarde." + }, + "unauthorized": { + "title": "No autorizado", + "description": "Esta nota es privada. Debe iniciar sesión para verla.", + "button": "Iniciar sesión" + }, + "note-not-found": { + "title": "Nota no encontrada", + "description": "Esta nota no existe, ha expirado o ha sido eliminada." + }, + "fetch-error": { + "title": "Ocurrió un error", + "description": "Ocurrió un error al recuperar la nota. Por favor, inténtelo de nuevo más tarde." + }, + "decryption": { + "title": "Ocurrió un error", + "description": "Ocurrió un error al descifrar la nota. La URL puede ser inválida." + } + } + }, + "copy": { + "label": "Copiar al portapapeles", + "success": "¡Copiado!" + } +} diff --git a/enclosed/packages/app-client/src/locales/fr.json b/enclosed/packages/app-client/src/locales/fr.json new file mode 100644 index 00000000..94cf4547 --- /dev/null +++ b/enclosed/packages/app-client/src/locales/fr.json @@ -0,0 +1,172 @@ +{ + "app": { + "title": "Enclosed", + "description": "Envoyez des notes privées et sécurisées" + }, + "insecureContextWarning": { + "description": "Votre connexion n'est pas sécurisée, l'application doit être servie via HTTPS pour fonctionner correctement. Vous ne pourrez pas créer ou consulter de notes.", + "learn-more": "En savoir plus" + }, + "navbar": { + "new-note": "Nouvelle note", + "github": "GitHub", + "language": "Langue", + "github-repository": "Dépôt GitHub", + "documentation": "Documentation", + "change-theme": "Changer de thème", + "menu-icon": "Icône du menu", + "change-language": "Changer de langue", + "theme": { + "theme": "Thème", + "light-mode": "Mode clair", + "dark-mode": "Mode sombre", + "system-mode": "Mode système" + }, + "settings": { + "documentation": "Documentation", + "cli": "Enclosed CLI", + "support": "Soutenir Enclosed", + "report-bug": "Signaler un bug", + "logout": "Se déconnecter", + "contribute-to-i18n": "Contribuer à l'i18n" + } + }, + "footer": { + "crafted-by": "Créé par", + "source-code": "Code source disponible sur", + "github": "GitHub", + "version": "Version" + }, + "login": { + "title": "Connexion à Enclosed", + "description": "Ceci est une instance privée de Enclosed. Entrez vos identifiants pour pouvoir créer des notes.", + "email": "Email", + "password": "Mot de passe", + "submit": "Se connecter", + "errors": { + "invalid-credentials": "Email ou mot de passe invalide.", + "unknown": "Une erreur inconnue est survenue. Veuillez réessayer plus tard." + }, + "footer": [ + "Vous n'avez pas de compte ?", + "Contactez le propriétaire de l'instance." + ] + }, + "create": { + "errors": { + "empty-note": "Veuillez entrer le contenu d'une note ou joindre un fichier.", + "rate-limit": "Vous avez dépassé la limite de création de notes. Veuillez réessayer plus tard.", + "too-large": "Le contenu de la note et les pièces jointes sont trop volumineux. Veuillez réduire la taille et réessayer.", + "unauthorized": "Vous n'êtes pas autorisé à créer des notes. Veuillez vous connecter et réessayer.", + "api-not-found": "L'endpoint API n'a pas été trouvé. Le serveur peut ne pas être correctement configuré.", + "unknown": "Une erreur est survenue lors de la création de la note, veuillez réessayer." + }, + "share": { + "button": "Partager la note", + "title": "Note partagée", + "description": "Voici une note partagée avec vous." + }, + "settings": { + "placeholder": "Saisissez votre note ici...", + "password": { + "label": "Mot de passe de la note", + "placeholder": "Mot de passe...", + "hide-password": "Masquer le mot de passe", + "show-password": "Afficher le mot de passe", + "generate-random-password": "Générer un mot de passe aléatoire" + }, + "expiration": "Délai d'expiration", + "no-expiration": "La note n'expirera jamais", + "delays": { + "1h": "1 heure", + "1d": "1 jour", + "1w": "1 semaine", + "1m": "1 mois" + }, + "delete-after-reading": { + "label": "Supprimer après lecture", + "description": "Supprimer la note après lecture" + }, + "attach-files": "Joindre des fichiers", + "drop-files": { + "title": "Déposez les fichiers ici", + "description": "Glissez et déposez les fichiers ici pour les joindre à la note" + }, + "create": "Créer une note", + "creating": "Création en cours..." + }, + "success": { + "title": "Note créée avec succès", + "description": "Votre note a été créée. Vous pouvez maintenant la partager à l'aide du lien suivant.", + "with-deletion": "La note sera supprimée après lecture.", + "copy-link": "Copier le lien", + "copy-success": "Lien copié" + }, + "qr-code": { + "title": "Partager cette note sur mobile", + "description": "Scannez ce QR code pour ouvrir la note sur votre appareil mobile. Vous pouvez également exporter le QR code pour le partager.", + "export": "Exporter le QR code", + "download-svg": "Télécharger au format SVG", + "download-png": "Télécharger au format PNG", + "download-success": "Téléchargement réussi", + "copy-svg": "Copier le code du SVG", + "copy-success": "SVG copié dans le presse-papiers" + } + }, + "view": { + "loading": "Chargement de la note...", + "note-content": "Contenu de la note", + "assets": { + "download": "Télécharger", + "download-all": "Tout télécharger", + "heading-multiple": "{{ count }} fichiers joints à cette note", + "heading-single": "1 fichier joint à cette note" + }, + "request-password": { + "description": "Cette note est protégée par un mot de passe. Veuillez entrer le mot de passe pour la déverrouiller.", + "form": { + "label": "Mot de passe", + "placeholder": "Mot de passe de la note...", + "unlock-button": "Déverrouiller la note", + "invalid": "Le mot de passe que vous avez entré est incorrect ou l'URL de la note est incorrecte." + } + }, + "warn-for-note-deletion": { + "title": "Cette note sera supprimée après lecture", + "description": "Cette note sera supprimée après lecture. Une fois affichée, elle sera définitivement supprimée et ne pourra pas être récupérée.", + "confirm": "Je comprends, montrer la note" + }, + + "error": { + "invalid-url": { + "title": "URL de note invalide", + "description": "Cette URL de note est invalide. Veuillez vous assurer d'utiliser la bonne URL." + }, + "rate-limit": { + "title": "Limite de taux dépassée", + "description": "Vous avez dépassé la limite de taux pour récupérer des notes. Veuillez réessayer plus tard." + }, + "unauthorized": { + "title": "Non autorisé", + "description": "Cette note est privée. Vous devez être connecté pour la voir.", + "button": "Se connecter" + }, + "note-not-found": { + "title": "Note introuvable", + "description": "Cette note n'existe pas, a expiré ou a été supprimée." + }, + "fetch-error": { + "title": "Une erreur s'est produite", + "description": "Une erreur s'est produite lors de la récupération de la note. Veuillez réessayer plus tard." + }, + "decryption": { + "title": "Une erreur s'est produite", + "description": "Une erreur s'est produite lors du déchiffrement de la note. L'URL peut être invalide." + } + } + }, + "copy": { + "label": "Copier", + "success": "Copié!" + } +} diff --git a/enclosed/packages/app-client/src/locales/hu.json b/enclosed/packages/app-client/src/locales/hu.json new file mode 100644 index 00000000..45a8a59b --- /dev/null +++ b/enclosed/packages/app-client/src/locales/hu.json @@ -0,0 +1,161 @@ +{ + "app": { + "title": "Enclosed", + "description": "Küldj privát és biztonságos jegyzeteket" + }, + "insecureContextWarning": { + "description": "A kapcsolat nem biztonságos, az alkalmazásnak a HTTPS protokolon keresztül kell futnia a megfelelő működés érdekében. Nem fogsz tudni jegyzeteket megtekinteni vagy létrehozni.", + "learn-more": "További információ" + }, + "navbar": { + "new-note": "Új jegyzet", + "github": "GitHub", + "language": "Nyelv", + "theme": { + "theme": "Megjelenés", + "light-mode": "Világos mód", + "dark-mode": "Sötét mód", + "system-mode": "Rendszer" + }, + "settings": { + "documentation": "Dokumentáció", + "cli": "Enclosed CLI", + "support": "Támogasd az Enclosed-ot", + "report-bug": "Hiba bejelentése", + "logout": "Kijelentkezés", + "contribute-to-i18n": "Közreműködés az i18n-ben" + } + }, + "footer": { + "crafted-by": "Készítette", + "source-code": "Forráskód elérhető a", + "github": "GitHubon", + "version": "Verzió" + }, + "login": { + "title": "Bejelentkezés az Enclosedba", + "description": "Ez az Enclosed egy privát példánya. Add meg hitelesítő adataidat a jegyzetek létrehozásához.", + "email": "Email", + "password": "Jelszó", + "submit": "Bejelentkezés", + "errors": { + "invalid-credentials": "Érvénytelen e-mail vagy jelszó.", + "unknown": "Ismeretlen hiba történt. Kérjük, próbálkozz később újra." + }, + "footer": [ + "Nincs fiókod?", + "Vedd fel a kapcsolatot az Enclosed privát példányának tulajdonosával." + ] + }, + "create": { + "errors": { + "empty-note": "Kérjük, adj meg jegyzettartalmat vagy csatolj egy fájlt.", + "rate-limit": "Túllépted a jegyzetek létrehozásának korlátját. Kérjük, próbálkozz később.", + "too-large": "A jegyzet tartalma és a mellékletek túl nagyok. Kérjük, csökkentsd a méretet, és próbálja újra.", + "unauthorized": "Nincs jogosultságod jegyzetek létrehozására. Kérjük, jelentkezz be, és próbálkozz újra.", + "api-not-found": "Az API végpont nem található. A szerver nincs megfelelően konfigurálva.", + "unknown": "Hiba történt a jegyzet létrehozása közben, kérjük, próbálkozz újra." + }, + "share": { + "button": "Jegyzet megosztása", + "title": "Megosztott jegyzet", + "description": "Íme egy veled megosztott jegyzet." + }, + "settings": { + "placeholder": "Írd ide a jegyzeted...", + "password": { + "label": "Jegyzet jelszava", + "placeholder": "Jelszó..." + }, + "expiration": "Lejárati idő", + "delays": { + "1h": "1 óra", + "1d": "1 nap", + "1w": "1 hét", + "1m": "1 hónap" + }, + "delete-after-reading": { + "label": "Törlés olvasás után", + "description": "A jegyzet törlése olvasás után" + }, + "attach-files": "Fájlok csatolása", + "drop-files": { + "title": "Dobd ide a fájlokat", + "description": "Húzd ide a fájlokat a jegyzethez csatoláshoz" + }, + "create": "Jegyzet létrehozása", + "creating": "Jegyzet létrehozása folyamatban..." + }, + "success": { + "title": "A jegyzet sikeresen létrejött", + "description": "A jegyzet létrejött. Most már megoszthatod a következő link segítségével.", + "with-deletion": "A jegyzet törlődik olvasás után.", + "copy-link": "Link másolása", + "copy-success": "Link másolva" + }, + "qr-code": { + "title": "Jegyzet megosztása mobilon", + "description": "Mobil eszközön történő megtekintéshez olvasd be az alábbi QR-kódot. A QR-kódot képként is exportálható, hogy megoszd másokkal.", + "export": "QR-kód exportálása", + "download-svg": "Letöltés SVG formátumban", + "download-png": "Letöltés PNG formátumban", + "download-success": "QR-kód letöltve", + "copy-svg": "SVG kód másolása", + "copy-success": "SVG másolva a vágólapra" + } + }, + "view": { + "note-content": "Jegyzet tartalma", + "assets": { + "download": "Letöltés", + "download-all": "Összes fájl letöltése", + "heading-multiple": "{{ count }} fájl csatolva ehhez a jegyzethez", + "heading-single": "1 fájl csatolva ehhez a jegyzethez" + }, + "request-password": { + "description": "Ez a jegyzet jelszóval védett. Kérjük, add meg a jelszót a feloldáshoz.", + "form": { + "label": "Jelszó", + "placeholder": "Jegyzet jelszava...", + "unlock-button": "Jegyzet feloldása", + "invalid": "A megadott jelszó érvénytelen, vagy a jegyzet URL helytelen." + } + }, + "warn-for-note-deletion": { + "title": "Ez a jegyzet törlődni fog", + "description": "Ez a jegyzet úgy lett beállítva, hogy olvasás után automatikusan törlődjön. A jegyzet megjelenítése után véglegesen törlődik és nem lesz visszaállítható.", + "confirm": "Megértettem, tovább a jegyzethez" + }, + "error": { + "invalid-url": { + "title": "Érvénytelen jegyzet URL", + "description": "Ez a jegyzet URL érvénytelen. Kérjük, ellenőrizd, hogy a helyes URL-t használod e." + }, + "rate-limit": { + "title": "Sebességkorlát túllépve", + "description": "Túllépted a jegyzetek lekérésére vonatkozó sebességkorlátot. Kérjük, próbálkozz később." + }, + "unauthorized": { + "title": "Nem engedélyezett", + "description": "Ez a jegyzet privát. A megtekintéshez be kell jelentkezned.", + "button": "Bejelentkezés" + }, + "note-not-found": { + "title": "Jegyzet nem található", + "description": "Ez a jegyzet nem létezik, lejárt vagy törölték." + }, + "fetch-error": { + "title": "Hiba történt", + "description": "Hiba történt a jegyzet lekérése közben. Kérjük, próbálkozz később." + }, + "decryption": { + "title": "Hiba történt", + "description": "Hiba történt a jegyzet dekódolása közben. Az URL lehet, hogy érvénytelen." + } + } + }, + "copy": { + "label": "Másolás a vágólapra", + "success": "Másolva!" + } +} diff --git a/enclosed/packages/app-client/src/locales/it.json b/enclosed/packages/app-client/src/locales/it.json new file mode 100644 index 00000000..79c2f425 --- /dev/null +++ b/enclosed/packages/app-client/src/locales/it.json @@ -0,0 +1,172 @@ +{ + "app": { + "title": "Enclosed", + "description": "Invia note private e sicure" + }, + "insecureContextWarning": { + "description": "La connessione non è sicura, l'applicativo deve viaggiare sotto protocollo HTTPS per funzionare correttamente. Non puoi creare o leggere le note.", + "learn-more": "Per saperne di più" + }, + "navbar": { + "new-note": "Nuova nota", + "github": "GitHub", + "language": "Lingua", + "github-repository": "Repository GitHub", + "documentation": "Documentazione", + "change-theme": "Cambia tema", + "menu-icon": "Icona menu", + "change-language": "Cambia lingua", + "theme": { + "theme": "Tema", + "light-mode": "Tema chiaro", + "dark-mode": "Tema scuro", + "system-mode": "Come il sistema" + }, + "settings": { + "documentation": "Documentazione", + "cli": "Enclosed CLI", + "support": "Sostieni Enclosed", + "report-bug": "Segnala un bug", + "logout": "Disconnetti", + "contribute-to-i18n": "Contribuisci alla traduzione" + } + }, + "footer": { + "crafted-by": "Creato da", + "source-code": "Codice sorgente disponibile su", + "github": "GitHub", + "version": "Versione" + }, + "login": { + "title": "Accedi a Enclosed", + "description": "Questa è un'istanza privata di Enclosed. Accedi per creare delle note.", + "email": "Email", + "password": "Password", + "submit": "Accedi", + "errors": { + "invalid-credentials": "Email o password non validi.", + "unknown": "Errore sconosciuto. Riprova più tardi." + }, + "footer": [ + "Non hai un account?", + "Contatta il proprietario dell'istanza." + ] + }, + "create": { + "errors": { + "empty-note": "Puoi scrivere il contenuto di una nota o allegare un file.", + "rate-limit": "Hai superato il limite di creazione delle note. Riprova più tardi.", + "too-large": "Il contenuto della nota o degli allegati è troppo grande. Riduci la dimensione e riprova.", + "unauthorized": "Non sei autorizzato a creare delle note. Accedi e riprova.", + "api-not-found": "L'endpoint API non è stato trovato. Il server potrebbe non essere configurato correttamente.", + "unknown": "C'è stato un errore nel creare la nota. Riprova più tardi." + }, + "share": { + "button": "Condividi", + "title": "Nota condivisa", + "description": "Visualizza una nota condivisa." + }, + "settings": { + "placeholder": "Scrivi qui la nota...", + "password": { + "label": "Password della nota", + "placeholder": "Password...", + "hide-password": "Nascondi password", + "show-password": "Mostra password", + "generate-random-password": "Genera una password casuale" + }, + "expiration": "Durata", + "no-expiration": "La nota non scade", + "delays": { + "1h": "1 ora", + "1d": "1 giorno", + "1w": "1 settimana", + "1m": "1 mese" + }, + "delete-after-reading": { + "label": "Cancella dopo la lettura", + "description": "Cancella la nota dopo la lettura" + }, + "attach-files": "Allega dei file", + "drop-files": { + "title": "Rilascia qui i file", + "description": "Usa il drag and drop per allegare i file alla nota" + }, + "create": "Crea una nota", + "creating": "Creazione in corso..." + }, + "success": { + "title": "Nota creata con successo", + "description": "La nota è stata creata con successo. Ora puoi condividerla usando il seguente link.", + "with-deletion": "La nota sarà cancellata dopo la lettura.", + "copy-link": "Copia link", + "copy-success": "Link copiato" + }, + "qr-code": { + "title": "Condividi questa nota su smartphone", + "description": "Scansiona questo QR code per aprire la nota sullo smartphone. È possibile condividere il QR code con altri.", + "export": "Esporta il QR code", + "download-svg": "Download SVG", + "download-png": "Download PNG", + "download-success": "Download riuscito", + "copy-svg": "Copia il codice del file SVG", + "copy-success": "SVG copiato" + } + }, + "view": { + "loading": "Carico la nota...", + "note-content": "Contenuto della nota", + "assets": { + "download": "Download", + "download-all": "Scarica tutti i file", + "heading-multiple": "{{ count }} file allegati alla nota", + "heading-single": "1 file allegato alla nota" + }, + "request-password": { + "description": "Questa nota è protetta da una password. Inserisci la password per sbloccarla.", + "form": { + "label": "Password", + "placeholder": "Password della nota...", + "unlock-button": "Sblocca la nota", + "invalid": "La password inserita non è corretta o l'URL della nota è sbagliata." + } + }, + "warn-for-note-deletion": { + "title": "Questa nota sarà cancellata", + "description": "Questa nota sarà cancellata dopo la lettura. Una volta visualizzata sarà definitivamente cancellata e non potrà essere recuperata.", + "confirm": "Capisco, visualizza la nota" + }, + + "error": { + "invalid-url": { + "title": "URL della nota non valida", + "description": "L'URL della nota non è valida. Assicurarsi di aver usato l'URL corretta." + }, + "rate-limit": { + "title": "Superato il limite di aggiornamenti", + "description": "È stato superato il limite di aggiornamenti per le note. Riprova più tardi." + }, + "unauthorized": { + "title": "Non autorizzato", + "description": "Questa nota è privata. Bisogna accedere per vederla.", + "button": "Accedi" + }, + "note-not-found": { + "title": "Nota non trovata", + "description": "Questa nota non esiste, è scaduta o è stata cancellata." + }, + "fetch-error": { + "title": "C'è stato un errore", + "description": "C'è stato un errore durante il recupero della nota. Riprovare più tardi." + }, + "decryption": { + "title": "C'è stato un errore", + "description": "C'è stato un errore durante la decifratura della nota. L'URL potrebbe non essere valida." + } + } + }, + "copy": { + "label": "Copia", + "success": "Copiato!" + } +} diff --git a/enclosed/packages/app-client/src/locales/locales.test.ts b/enclosed/packages/app-client/src/locales/locales.test.ts new file mode 100644 index 00000000..b4488ed4 --- /dev/null +++ b/enclosed/packages/app-client/src/locales/locales.test.ts @@ -0,0 +1,45 @@ +import { access, constants } from 'node:fs/promises'; +import { flatten } from '@solid-primitives/i18n'; +import { chain, difference, get, keys } from 'lodash-es'; +import { describe, expect, test } from 'vitest'; +import defaultLocale from './en.json'; +import { locales } from './locales'; + +function fileExists(relativePath: string) { + const path = new URL(relativePath, import.meta.url).pathname; + + return access(path, constants.F_OK).then(() => true).catch(() => false); +} + +const localesFiles = import.meta.glob('./*.json', { eager: true }); +const localesKeys = chain(localesFiles) + .map((value, key) => ({ + key: key.replace('./', '').replace('.json', ''), + value: get(value, 'default') as any, + })) + .reject(({ key }) => key === 'en') + .value() as { key: string; value: Record }[]; + +const flattenedDefault = flatten(defaultLocale); + +describe('locales', () => { + test('locales should not have extra keys compared to default locale', () => { + localesKeys.forEach(({ key, value }) => { + const flattened = flatten(value); + const extraKeys = difference(keys(flattened), keys(flattenedDefault)); + + expect(extraKeys).to.eql([], `Extra keys found in ${key}.json`); + }); + }); + + test('ensure all defined locales have a file in the locales folder', async () => { + for (const { key } of locales) { + expect(await fileExists(`./${key}.json`)).to.eql(true, `Missing file for locale ${key}`); + } + }); + + test('make sure locales are sorted by the native language name', () => { + const sortedLocales = [...locales].sort((a, b) => a.name.localeCompare(b.name)); + expect(locales).to.eql(sortedLocales); + }); +}); diff --git a/enclosed/packages/app-client/src/locales/locales.ts b/enclosed/packages/app-client/src/locales/locales.ts new file mode 100644 index 00000000..39e94bbb --- /dev/null +++ b/enclosed/packages/app-client/src/locales/locales.ts @@ -0,0 +1,19 @@ +// Order of locales matters, keep it sorted by the native language name +export const locales = [ + { key: 'de', name: 'Deutsch' }, + { key: 'en', name: 'English' }, + { key: 'es', name: 'Español' }, + { key: 'fr', name: 'Français' }, + { key: 'it', name: 'Italiano' }, + { key: 'hu', name: 'Magyar' }, + { key: 'nl', name: 'Nederlands' }, + { key: 'pl', name: 'Polski' }, + { key: 'pt', name: 'Português' }, + { key: 'pt-BR', name: 'Português (Brasil)' }, + { key: 'vi', name: 'Tiếng Việt' }, + { key: 'tr', name: 'Türkçe' }, + { key: 'ru', name: 'Русский' }, + { key: 'ar', name: 'العربية' }, + { key: 'zh-CN', name: '简体中文' }, + { key: 'zh-TW', name: '繁體中文' }, +] as const; diff --git a/enclosed/packages/app-client/src/locales/nl.json b/enclosed/packages/app-client/src/locales/nl.json new file mode 100644 index 00000000..07a5d8ce --- /dev/null +++ b/enclosed/packages/app-client/src/locales/nl.json @@ -0,0 +1,162 @@ +{ + "app": { + "title": "Enclosed", + "description": "Privé en beveiligde notities versturen" + }, + "insecureContextWarning": { + "description": "Je verbinding is niet beveiligd, de app moet over HTTPS worden geserveerd om goed te werken. Je kunt geen notities maken of bekijken.", + "learn-more": "Meer informatie" + }, + "navbar": { + "new-note": "Nieuwe notitie", + "github": "GitHub", + "language": "Taal", + "theme": { + "theme": "Thema", + "light-mode": "Lichte modus", + "dark-mode": "Donkere modus", + "system-mode": "Systeem" + }, + "settings": { + "documentation": "Documentatie", + "cli": "Bijgesloten CLI", + "support": "Ondersteun Enclosed", + "report-bug": "Meld een bug", + "logout": "Uitloggen", + "contribute-to-i18n": "Bijdragen aan i18n" + } + }, + "footer": { + "crafted-by": "Gemaakt door", + "source-code": "Broncode beschikbaar op", + "github": "GitHub", + "version": "Versie" + }, + "login": { + "title": "Inloggen op Enclosed", + "description": "Dit is een privé-instantie van Enclosed. Voer je gegevens in om notities te kunnen maken.", + "email": "Email", + "password": "Wachtwoord", + "submit": "Inloggen", + "errors": { + "invalid-credentials": "Ongeldig e-mailadres of wachtwoord.", + "unknown": "Er is een onbekende fout opgetreden. Probeer het later nog eens." + }, + "footer": [ + "Heb je geen account?", + "Neem contact op met de eigenaar van de instantie." + ] + }, + "create": { + "errors": { + "empty-note": "Voer de inhoud van de notitie in of voeg een bestand bij.", + "rate-limit": "Je hebt de limiet voor het maken van notities overschreden. Probeer het later nog eens.", + "too-large": "De inhoud van de notitie en de bijlagen zijn te groot. Verklein het bestand en probeer het opnieuw.", + "unauthorized": "Je bent niet gemachtigd om notities te maken. Log in en probeer het opnieuw.", + "api-not-found": "Het API-eindpunt is niet gevonden. De server is mogelijk niet correct geconfigureerd.", + "unknown": "Er is een fout opgetreden bij het maken van de notitie, probeer het opnieuw." + }, + "share": { + "button": "Notitie delen", + "title": "Gedeelde notitie", + "description": "Hier is een notitie die met jou is gedeeld." + }, + "settings": { + "placeholder": "Schrijf je notitie hier...", + "password": { + "label": "Notitie wachtwoord", + "placeholder": "Wachtwoord..." + }, + "expiration": "Verloopmoment", + "no-expiration": "Notitie verloopt nooit", + "delays": { + "1h": "1 uur", + "1d": "1 dag", + "1w": "1 week", + "1m": "1 maand" + }, + "delete-after-reading": { + "label": "Verwijder na lezen", + "description": "Verwijder de notitie na het lezen" + }, + "attach-files": "Bestanden toevoegen", + "drop-files": { + "title": "Drop bestanden hier", + "description": "Sleep bestanden hierheen om ze bij de notitie te voegen" + }, + "create": "Notitie aanmaken", + "creating": "Notitie maken..." + }, + "success": { + "title": "Notitie succesvol aangemaakt", + "description": "Je notitie is aangemaakt. Je kunt het nu delen via de volgende link.", + "with-deletion": "De notitie wordt na het lezen verwijderd.", + "copy-link": "Link kopiëren", + "copy-success": "Link gekopieerd" + }, + "qr-code": { + "title": "Deel deze notitie via een mobiel", + "description": "Je kunt deze QR-code scannen om de notitie op je mobiele apparaat te bekijken. Je kunt de QR code ook exporteren om deze met anderen te delen.", + "export": "QR code exporteren", + "download-svg": "Downloaden als SVG", + "download-png": "Downloaden als PNG", + "download-success": "QR-code gedownload", + "copy-svg": "SVG-code kopiëren", + "copy-success": "SVG gekopieerd naar klembord" + } + }, + "view": { + "note-content": "Notitie inhoud", + "assets": { + "download": "Downloaden", + "download-all": "Alle bestanden downloaden", + "heading-multiple": "{{ count }} bestanden als bijlagen bij deze notitie", + "heading-single": "1 bestand als bijlage bij deze notitie" + }, + "request-password": { + "description": "Deze notitie is beveiligd met een wachtwoord. Voer het wachtwoord in om het te ontgrendelen.", + "form": { + "label": "Wachtwoord", + "placeholder": "Notitie wachtwoord...", + "unlock-button": "Ontgrendel notitie", + "invalid": "Het ingevoerde wachtwoord is ongeldig of de URL van de notitie is onjuist." + } + }, + "warn-for-note-deletion": { + "title": "Deze notitie wordt verwijderd", + "description": "Deze notitie is geconfigureerd om verwijderd te worden na het lezen. Na het weergeven van de notitie wordt ze permanent verwijderd en kan ze niet meer hersteld worden.", + "confirm": "Ik begrijp het, laat me de notitie zien" + }, + "error": { + "invalid-url": { + "title": "Ongeldige notitie URL", + "description": "De URL van deze notitie is ongeldig. Controleer of je de juiste URL gebruikt." + }, + "rate-limit": { + "title": "Gebruikslimiet overschreden", + "description": "Je hebt de limiet voor het ophalen van notities overschreden. Probeer het later nog eens." + }, + "unauthorized": { + "title": "Ongeautoriseerd", + "description": "Deze notitie is privé. Je moet ingelogd zijn om het te bekijken.", + "button": "Inloggen" + }, + "note-not-found": { + "title": "Notitie niet gevonden", + "description": "Deze notitie bestaat niet, is verlopen of is verwijderd." + }, + "fetch-error": { + "title": "Er is een fout opgetreden", + "description": "Er is een fout opgetreden tijdens het ophalen van de notitie. Probeer het later nog eens." + }, + "decryption": { + "title": "Er is een fout opgetreden", + "description": "Er is een fout opgetreden tijdens het decoderen van de notitie. De url is mogelijk ongeldig." + } + } + }, + "copy": { + "label": "Kopiëren naar klembord", + "success": "Gekopieerd!" + } +} diff --git a/enclosed/packages/app-client/src/locales/pl.json b/enclosed/packages/app-client/src/locales/pl.json new file mode 100644 index 00000000..5250e69c --- /dev/null +++ b/enclosed/packages/app-client/src/locales/pl.json @@ -0,0 +1,171 @@ +{ + "app": { + "title": "Enclosed", + "description": "Wysyłaj prywatne i bezpieczne notatki" + }, + "insecureContextWarning": { + "description": "Twoje połączenie nie jest bezpieczne. Aplikacja musi być obsługiwana przez HTTPS, aby działać poprawnie. Nie będziesz mógł tworzyć ani przeglądać notatek.", + "learn-more": "Dowiedz się więcej" + }, + "navbar": { + "new-note": "Nowa notatka", + "github": "GitHub", + "language": "Język", + "github-repository": "Repozytorium GitHub", + "documentation": "Dokumentacja", + "change-theme": "Zmień motyw", + "menu-icon": "Ikona menu", + "change-language": "Zmień język", + "theme": { + "theme": "Motyw", + "light-mode": "Tryb jasny", + "dark-mode": "Tryb ciemny", + "system-mode": "Systemowy" + }, + "settings": { + "documentation": "Dokumentacja", + "cli": "Enclosed CLI", + "support": "Wspieraj Enclosed", + "report-bug": "Zgłoś błąd", + "logout": "Wyloguj", + "contribute-to-i18n": "Współtwórz tłumaczenie" + } + }, + "footer": { + "crafted-by": "Stworzone przez", + "source-code": "Kod źródłowy dostępny na", + "github": "GitHub", + "version": "Wersja" + }, + "login": { + "title": "Zaloguj się do Enclosed", + "description": "To jest prywatna instancja Enclosed. Wprowadź swoje dane, aby móc tworzyć notatki.", + "email": "E-mail", + "password": "Hasło", + "submit": "Zaloguj się", + "errors": { + "invalid-credentials": "Nieprawidłowy e-mail lub hasło.", + "unknown": "Wystąpił nieznany błąd. Spróbuj ponownie później." + }, + "footer": [ + "Nie masz konta?", + "Skontaktuj się z właścicielem instancji." + ] + }, + "create": { + "errors": { + "empty-note": "Wprowadź treść notatki lub dołącz plik.", + "rate-limit": "Przekroczyłeś limit tworzenia notatek. Spróbuj ponownie później.", + "too-large": "Treść notatki i załączniki są zbyt duże. Zmniejsz rozmiar i spróbuj ponownie.", + "unauthorized": "Nie masz uprawnień do tworzenia notatek. Zaloguj się i spróbuj ponownie.", + "api-not-found": "Nie znaleziono punktu końcowego API. Serwer może być nieprawidłowo skonfigurowany.", + "unknown": "Wystąpił błąd podczas tworzenia notatki. Spróbuj ponownie." + }, + "share": { + "button": "Udostępnij notatkę", + "title": "Udostępniona notatka", + "description": "Oto notatka udostępniona dla Ciebie." + }, + "settings": { + "placeholder": "Wpisz swoją notatkę tutaj...", + "password": { + "label": "Hasło notatki", + "placeholder": "Hasło...", + "hide-password": "Ukryj hasło", + "show-password": "Pokaż hasło", + "generate-random-password": "Wygeneruj losowe hasło" + }, + "expiration": "Czas ważności", + "no-expiration": "Notatka nigdy nie wygasa", + "delays": { + "1h": "1 godzina", + "1d": "1 dzień", + "1w": "1 tydzień", + "1m": "1 miesiąc" + }, + "delete-after-reading": { + "label": "Usuń po przeczytaniu", + "description": "Usuń notatkę po przeczytaniu" + }, + "attach-files": "Dołącz pliki", + "drop-files": { + "title": "Upuść pliki tutaj", + "description": "Przeciągnij i upuść pliki tutaj, aby dołączyć je do notatki" + }, + "create": "Utwórz notatkę", + "creating": "Tworzenie notatki..." + }, + "success": { + "title": "Notatka utworzona pomyślnie", + "description": "Twoja notatka została utworzona. Możesz ją teraz udostępnić, używając poniższego linku.", + "with-deletion": "Notatka zostanie usunięta po przeczytaniu.", + "copy-link": "Kopiuj link", + "copy-success": "Link skopiowany" + }, + "qr-code": { + "title": "Udostępnij notatkę na urządzeniu mobilnym", + "description": "Możesz zeskanować ten kod QR, aby wyświetlić notatkę na urządzeniu mobilnym. Możesz także wyeksportować obraz kodu QR, aby udostępnić go innym.", + "export": "Eksportuj kod QR", + "download-svg": "Pobierz jako SVG", + "download-png": "Pobierz jako PNG", + "download-success": "Kod QR pobrany", + "copy-svg": "Kopiuj kod SVG", + "copy-success": "Kod SVG skopiowany do schowka" + } + }, + "view": { + "loading": "Ładowanie notatki...", + "note-content": "Treść notatki", + "assets": { + "download": "Pobierz", + "download-all": "Pobierz wszystkie pliki", + "heading-multiple": "{{ count }} plików załączone do tej notatki", + "heading-single": "1 plik załączony do tej notatki" + }, + "request-password": { + "description": "Ta notatka jest chroniona hasłem. Wprowadź hasło, aby ją odblokować.", + "form": { + "label": "Hasło", + "placeholder": "Hasło notatki...", + "unlock-button": "Odblokuj notatkę", + "invalid": "Podane hasło jest nieprawidłowe lub adres URL notatki jest błędny." + } + }, + "warn-for-note-deletion": { + "title": "Ta notatka zostanie usunięta", + "description": "Ta notatka została skonfigurowana do usunięcia po przeczytaniu. Po wyświetleniu notatki zostanie trwale usunięta i nie będzie można jej odzyskać.", + "confirm": "Rozumiem, pokaż mi notatkę" + }, + "error": { + "invalid-url": { + "title": "Nieprawidłowy adres URL notatki", + "description": "Ten adres URL notatki jest nieprawidłowy. Upewnij się, że korzystasz z poprawnego adresu." + }, + "rate-limit": { + "title": "Przekroczono limit", + "description": "Przekroczyłeś limit wyświetlania notatek. Spróbuj ponownie później." + }, + "unauthorized": { + "title": "Brak uprawnień", + "description": "Ta notatka jest prywatna. Musisz się zalogować, aby ją wyświetlić.", + "button": "Zaloguj się" + }, + "note-not-found": { + "title": "Nie znaleziono notatki", + "description": "Ta notatka nie istnieje, wygasła lub została usunięta." + }, + "fetch-error": { + "title": "Wystąpił błąd", + "description": "Wystąpił błąd podczas pobierania notatki. Spróbuj ponownie później." + }, + "decryption": { + "title": "Wystąpił błąd", + "description": "Wystąpił błąd podczas odszyfrowywania notatki. Adres URL może być nieprawidłowy." + } + } + }, + "copy": { + "label": "Kopiuj do schowka", + "success": "Skopiowano!" + } +} diff --git a/enclosed/packages/app-client/src/locales/pt-BR.json b/enclosed/packages/app-client/src/locales/pt-BR.json new file mode 100644 index 00000000..c7f589da --- /dev/null +++ b/enclosed/packages/app-client/src/locales/pt-BR.json @@ -0,0 +1,161 @@ +{ + "app": { + "title": "Enclosed", + "description": "Envie notas de forma privada e segura" + }, + "insecureContextWarning": { + "description": "Sua conexão não é segura, a aplicação deve ser disponibilizada usando HTTPS. Não será possível criar ou visualizar notas.", + "learn-more": "Ver mais" + }, + "navbar": { + "new-note": "Nova nota", + "github": "GitHub", + "language": "Idioma", + "theme": { + "theme": "Aparência", + "light-mode": "Claro", + "dark-mode": "Escuro", + "system-mode": "Sistema" + }, + "settings": { + "documentation": "Documentação", + "cli": "Enclosed CLI", + "support": "Ajudar Enclosed", + "report-bug": "Reportar erro", + "logout": "Sair", + "contribute-to-i18n": "Contribuir para i18n" + } + }, + "footer": { + "crafted-by": "Criado por", + "source-code": "Código disponível no", + "github": "GitHub", + "version": "Versão" + }, + "login": { + "title": "Entrar no Enclosed", + "description": "Esta é uma instância privada do Enclosed. Insira os dados da sua conta para poder criar notas.", + "email": "E-mail", + "password": "Senha", + "submit": "Entrar", + "errors": { + "invalid-credentials": "E-mail ou senha inválidos.", + "unknown": "Ocorreu um erro. Tente novamente mais tarde." + }, + "footer": [ + "Não tem uma conta?", + "Entre em contato com o dono da instância." + ] + }, + "create": { + "errors": { + "empty-note": "Escreva uma nota ou anexe um arquivo.", + "rate-limit": "Limite de criação de notas excedido. Tente novamente mais tarde.", + "too-large": "O conteúdo e/ou anexos da nota são muito grandes. Reduza o tamanho e tente novamente.", + "unauthorized": "Você não está autorizado a criar notas. Entre na sua conta e tente novamente.", + "api-not-found": "Servidor da API não encontrado. Verifique se o servidor está configurado corretamente.", + "unknown": "Ocorreu um erro ao criar a nota, tente outra vez." + }, + "share": { + "button": "Compartilhar nota", + "title": "Nota compartilhada", + "description": "Aqui está uma nota compartilhada com você." + }, + "settings": { + "placeholder": "Escreva sua nota aqui...", + "password": { + "label": "Senha da nota", + "placeholder": "Senha..." + }, + "expiration": "Tempo de expiração", + "delays": { + "1h": "1 hora", + "1d": "1 dia", + "1w": "1 semana", + "1m": "1 mês" + }, + "delete-after-reading": { + "label": "Apagar após leitura", + "description": "Apagar a nota depois de ser lida" + }, + "attach-files": "Anexar arquivos", + "drop-files": { + "title": "Solte arquivos aqui", + "description": "Arraste e solte arquivos aqui para anexá-los à nota" + }, + "create": "Criar nota", + "creating": "Criando nota..." + }, + "success": { + "title": "Nota criada com sucesso", + "description": "Sua nota foi criada. Agora você pode compartilhá-la com o endereço abaixo.", + "with-deletion": "A nota será apagada depois de lida.", + "copy-link": "Copiar endereço", + "copy-success": "Endereço copiado" + }, + "qr-code": { + "title": "Compartilhar com dispositivos móveis", + "description": "Você pode escanear este QR code para ver a nota em seu dispositivo móvel. Você também pode exportar a imagem do QR code para compartilhá-lo com outras pessoas.", + "export": "Exportar QR code", + "download-svg": "Baixar como SVG", + "download-png": "Baixar como PNG", + "download-success": "QR code baixado", + "copy-svg": "Copiar código SVG", + "copy-success": "SVG copiado" + } + }, + "view": { + "note-content": "Conteúdo da nota", + "assets": { + "download": "Baixar", + "download-all": "Baixar todos arquivos", + "heading-multiple": "{{ count }} arquivos anexados à nota", + "heading-single": "1 arquivo anexado à nota" + }, + "request-password": { + "description": "Esta nota está protegida por senha. Insira a senha para desbloqueá-la.", + "form": { + "label": "Senha", + "placeholder": "Senha da nota...", + "unlock-button": "Desbloquear nota", + "invalid": "A senha informada está incorreta ou o endereço da nota está inválido." + } + }, + "warn-for-note-deletion": { + "title": "Esta nota será apagada", + "description": "Esta nota foi configurada para ser apagada depois de lida. Após mostrar a nota, ela será permanentemente apagada e não poderá ser recuperada.", + "confirm": "Entendi, mostre a nota" + }, + "error": { + "invalid-url": { + "title": "Endereço de nota inválido", + "description": "Este endereço de nota é inválido. Verifique se está usando o endereço correto." + }, + "rate-limit": { + "title": "Limite excedido", + "description": "Você excedeu o limite de visualização de notas. Tente novamente mais tarde." + }, + "unauthorized": { + "title": "Não autorizado", + "description": "Esta nota é privada. Você precisa entrar na sua conta para visualizá-la.", + "button": "Entrar" + }, + "note-not-found": { + "title": "Nota não encontrada", + "description": "Esta nota não existe, expirou ou foi apagada." + }, + "fetch-error": { + "title": "Ocorreu um erro", + "description": "Ocorreu um erro ao carregar a nota. Tente novamente mais tarde." + }, + "decryption": { + "title": "Ocorreu um erro", + "description": "Ocorreu um erro ao descriptografar a nota. O endereço da nota pode estar inválido." + } + } + }, + "copy": { + "label": "Copiar", + "success": "Copiada!" + } +} diff --git a/enclosed/packages/app-client/src/locales/pt.json b/enclosed/packages/app-client/src/locales/pt.json new file mode 100644 index 00000000..dcf9ffdc --- /dev/null +++ b/enclosed/packages/app-client/src/locales/pt.json @@ -0,0 +1,146 @@ +{ + "app": { + "title": "Enclosed", + "description": "Envia notas de forma privada e segura" + }, + "insecureContextWarning": { + "description": "A conexão não é segura, a aplicação deve ser servida através de HTTPS. Não será possível criar ou visualizar notas.", + "learn-more": "Ver mais" + }, + "navbar": { + "new-note": "Nova nota", + "github": "GitHub", + "language": "Linguagem", + "theme": { + "theme": "Tema", + "light-mode": "Modo claro", + "dark-mode": "Modo escuro", + "system-mode": "Sistema" + }, + "settings": { + "documentation": "Documentação", + "cli": "Enclosed CLI", + "support": "Ajudar Enclosed", + "report-bug": "Reportar erro", + "logout": "Terminar sessão", + "contribute-to-i18n": "Contribuir para i18n" + } + }, + "footer": { + "crafted-by": "Criado por", + "source-code": "Código disponível no", + "github": "GitHub", + "version": "Versão" + }, + "login": { + "title": "Iniciar sessão no Enclosed", + "description": "Esta é uma instância privada do Enclosed. Insere as tuas credenciais para poderes criar notas.", + "email": "Email", + "password": "Palavra-passe", + "submit": "Iniciar sessão", + "errors": { + "invalid-credentials": "Email ou palavra-passe inválidos.", + "unknown": "Ocorreu um erro. Tenta outra vez mais tarde." + }, + "footer": ["Não tens conta?", "Contacta o criador da instância."] + }, + "create": { + "errors": { + "empty-note": "Escreve uma nota ou anexa um ficheiro.", + "rate-limit": "Limite de criação de notas excedido. Tenta outra vez mais tarde.", + "too-large": "O conteúdo na nota e/ou os anexos são demasiado grandes. Reduz o tamanho e tenta outra vez.", + "unauthorized": "Não estás autorizado a criar notas. Inicia sessão e tenta outra vez.", + "api-not-found": "Link da API não encontrado. É possível que o servidor esteja configurado incorretamente.", + "unknown": "Ocorreu um erro ao criar a nota, tenta outra vez." + }, + "share": { + "button": "Partilhar nota", + "title": "Nota partilhada", + "description": "Aqui está uma nota partilhada contigo." + }, + "settings": { + "placeholder": "Escreve a tua nota aqui...", + "password": { + "label": "Palavra-passe da nota", + "placeholder": "Palavra-passe..." + }, + "expiration": "Prazo de expiração", + "delays": { + "1h": "1 hora", + "1d": "1 dia", + "1w": "1 semana", + "1m": "1 mês" + }, + "delete-after-reading": { + "label": "Apagar depois de ler", + "description": "Apagar a nota depois de leitura" + }, + "attach-files": "Anexar ficheiros", + "drop-files": { + "title": "Soltar ficheiros aqui", + "description": "Arrasta e solta ficheiros aqui para anexá-los à nota" + }, + "create": "Criar nota", + "creating": "A criar nota..." + }, + "success": { + "title": "Nota criada com sucesso", + "description": "A tua nota foi criada. Consegues partilhá-la usando este link.", + "with-deletion": "A nota vai ser apagada depois de lida.", + "copy-link": "Copiar link", + "copy-success": "Link copiado" + } + }, + "view": { + "note-content": "Conteúdo da nota", + "assets": { + "download": "Descarregar", + "download-all": "Descarregar todos os ficheiros" + }, + "request-password": { + "description": "Esta nota está protegida por uma palavra-passe. Insere a palavra-passe para desbloqueá-la.", + "form": { + "label": "Palavra-passe", + "placeholder": "Palavra-passe da nota...", + "unlock-button": "Desbloquear nota", + "invalid": "Palavra-passe inserida ou URL da nota inválidos." + } + }, + "warn-for-note-deletion": { + "title": "Esta nota vai ser apagada", + "description": "Esta nota foi configurada para ser apagada depois de leitura. Depois de mostrar a nota, vai ser permanentemente apagada e não será possível recuperá-la.", + "confirm": "Compreendi, mostrar nota" + }, + "error": { + "invalid-url": { + "title": "URL da nota inválido", + "description": "O URL desta nota é inválido. Por favor, verifica se estás a usar o URL correto." + }, + "rate-limit": { + "title": "Limite excedido", + "description": "O limite de obtenção de notas foi excedido. Tenta outra vez mais tarde." + }, + "unauthorized": { + "title": "Não autorizado", + "description": "Esta nota é privada. É necessário iniciar sessão para visualizá-la.", + "button": "Iniciar sessão" + }, + "note-not-found": { + "title": "Nota não encontrada", + "description": "Esta nota não existe, expirou ou foi apagada." + }, + "fetch-error": { + "title": "Ocorreu um erro", + "description": "Ocorreu um erro ao obter a nota. Por favor, tenta outra vez mais tarde." + }, + "decryption": { + "title": "Ocorreu um erro", + "description": "Ocorreu um erro ao descodificar a nota. É possível que o URL seja inválido." + } + } + }, + "copy": { + "label": "Copiar para área de transferência", + "success": "Copiado!" + } +} diff --git a/enclosed/packages/app-client/src/locales/ru.json b/enclosed/packages/app-client/src/locales/ru.json new file mode 100644 index 00000000..b70b9680 --- /dev/null +++ b/enclosed/packages/app-client/src/locales/ru.json @@ -0,0 +1,141 @@ +{ + "app": { + "title": "Enclosed", + "description": "Отправляйте приватные и защищенные заметки" + }, + "insecureContextWarning": { + "description": "Ваше соединение небезопасно, для правильной работы приложения используйте протокол HTTPS. Вы не сможете создавать или просматривать заметки.", + "learn-more": "Узнать больше" + }, + "navbar": { + "new-note": "Новая заметка", + "github": "GitHub", + "theme": { + "light-mode": "Светлая тема", + "dark-mode": "Тёмная тема", + "system-mode": "Система" + }, + "settings": { + "documentation": "Документация", + "cli": "Enclosed CLI", + "support": "Поддержать Enclosed", + "report-bug": "Сообщить об ошибке", + "logout": "Выйти", + "contribute-to-i18n": "Внести свой вклад в i18n" + } + }, + "footer": { + "crafted-by": "Разработано", + "source-code": "Исходный код здесь:", + "github": "GitHub", + "version": "Версия" + }, + "login": { + "title": "Войти в Enclosed", + "description": "Это приватный режим Enclosed. Введите учётные данные, чтобы создать заметку.", + "email": "Электронная почта", + "password": "Пароль", + "submit": "Логин", + "errors": { + "invalid-credentials": "Неверный адрес электронной почты или пароль.", + "unknown": "Неизвестная ошибка. Пожалуйста, повторите попытку позже." + }, + "footer": [ + "Нет учётной записи?", + "Связаться с владельцем" + ] + }, + "create": { + "errors": { + "empty-note": "Введите содержание заметки или приложите файл", + "rate-limit": "Вы превысили лимит создания заметок. Пожалуйста, повторите попытку позже.", + "too-large": "Превышен объём содержания заметки и вложения. Уменьшите размер и повторите попытку.", + "unauthorized": "У вас нет прав для создания заметки. Войдите в систему и повторите попытку.", + "unknown": "При создании заметки произошла ошибка. Повторите попытку." + }, + "share": { + "button": "Поделиться", + "title": "Полученная заметка", + "description": "Делюсь своей заметкой." + }, + "settings": { + "placeholder": "Введите текст...", + "password": { + "label": "Пароль заметки", + "placeholder": "Пароль..." + }, + "expiration": "Срок истечения", + "delays": { + "1h": "1 час", + "1d": "1 день", + "1w": "1 неделя", + "1m": "1 месяц" + }, + "delete-after-reading": { + "label": "Удалить после прочтения", + "description": "Удалить заметку после прочтения" + }, + "attach-files": "Прикрепить файлы", + "drop-files": { + "title": "Перетащите файлы сюда", + "description": "Перетащите файлы, чтобы прикрепить их к заметке" + }, + "create": "Создать заметку", + "creating": "Создание заметки..." + }, + "success": { + "title": "Заметка успешно создана", + "description": "Заметка создана. Поделиться ", + "with-deletion": "Заметка будет удалена после прочтения.", + "copy-link": "Скопировать ссылку", + "copy-success": "Ссылка скопирована" + } + }, + "view": { + "note-content": "Содержание заметки", + "assets": { + "download": "Скачать", + "download-all": "Загрузить все файлы" + }, + "request-password": { + "description": "Заметка защищена паролем. Введите пароль, чтобы открыть ее.", + "form": { + "label": "Пароль", + "placeholder": "Пароль заметки...", + "unlock-button": "Открыть заметку", + "invalid": "Неверный пароль или URL-адрес заметки." + } + }, + "error": { + "invalid-url": { + "title": "Неверный URL-адрес заметки", + "description": "Ссылка недействительна. Введите корректный URL-адрес." + }, + "rate-limit": { + "title": "Превышен лимит", + "description": "Вы превысили лимит отправки заметок. Пожалуйста, повторите попытку позже." + }, + "unauthorized": { + "title": "Нет доступа", + "description": "Приватная заметка. Войдите в систему, чтобы просмотреть.", + "button": "Вход" + }, + "note-not-found": { + "title": "Заметка не найдена", + "description": "Заметка не существует, срок ее действия истек или она была удалена." + }, + "fetch-error": { + "title": "Ошибка", + "description": "При отправке заметки произошла ошибка. Пожалуйста, повторите попытку позже." + }, + "decryption": { + "title": "Ошибка", + "description": "При расшифровке заметки произошла ошибка. Неверный URL-адрес" + } + } + }, + "copy": { + "label": "Скопировать в буфер обмена", + "success": "Скопировано" + } +} diff --git a/enclosed/packages/app-client/src/locales/tr.json b/enclosed/packages/app-client/src/locales/tr.json new file mode 100644 index 00000000..fd83ca4a --- /dev/null +++ b/enclosed/packages/app-client/src/locales/tr.json @@ -0,0 +1,168 @@ +{ + "app": { + "title": "Enclosed", + "description": "Gizli ve güvenli notlar gönderin" + }, + "insecureContextWarning": { + "description": "Bağlantınız güvenli değil, uygulamanın düzgün çalışabilmesi için HTTPS üzerinden sunulması gerekir. Not oluşturamaz veya görüntüleyemezsiniz.", + "learn-more": "Daha fazla bilgi edin" + }, + "navbar": { + "new-note": "Yeni not", + "github": "GitHub", + "language": "Dil", + "github-repository": "GitHub deposu", + "documentation": "Dokümantasyon", + "change-theme": "Temayı değiştir", + "menu-icon": "Menü simgesi", + "change-language": "Dili değiştir", + "theme": { + "theme": "Tema", + "light-mode": "Aydınlık mod", + "dark-mode": "Karanlık mod", + "system-mode": "Sistem" + }, + "settings": { + "documentation": "Dokümantasyon", + "cli": "Enclosed CLI", + "support": "Enclosed’a destek olun", + "report-bug": "Hata bildir", + "logout": "Çıkış yap", + "contribute-to-i18n": "i18n’ye katkıda bulunun" + } + }, + "footer": { + "crafted-by": "Hazırlayan", + "source-code": "Kaynak kodu", + "github": "GitHub", + "version": "Sürüm" + }, + "login": { + "title": "Enclosed’a giriş yapın", + "description": "Bu, Enclosed’un özel bir sürümüdür. Not oluşturabilmek için kimlik bilgilerinizi girin.", + "email": "E-posta", + "password": "Şifre", + "submit": "Giriş yap", + "errors": { + "invalid-credentials": "Geçersiz e-posta veya şifre.", + "unknown": "Bilinmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyin." + }, + "footer": ["Hesabınız yok mu?", "Bu sistemin sahibine ulaşın."] + }, + "create": { + "errors": { + "empty-note": "Lütfen bir not içeriği girin veya bir dosya ekleyin.", + "rate-limit": "Not oluşturma hız sınırını aştınız. Lütfen daha sonra tekrar deneyin.", + "too-large": "Not içeriği ve ekler çok büyük. Lütfen boyutu küçültüp tekrar deneyin.", + "unauthorized": "Not oluşturma yetkiniz yok. Lütfen giriş yapın ve tekrar deneyin.", + "api-not-found": "API uç noktası bulunamadı. Sunucu yanlış yapılandırılmış olabilir.", + "unknown": "Not oluşturulurken bir hata oluştu, lütfen tekrar deneyin." + }, + "share": { + "button": "Notu paylaş", + "title": "Paylaşılan not", + "description": "Sizinle paylaşılan bir not." + }, + "settings": { + "placeholder": "Notunuzu buraya yazın...", + "password": { + "label": "Not şifresi", + "placeholder": "Şifre...", + "hide-password": "Şifreyi gizle", + "show-password": "Şifreyi göster", + "generate-random-password": "Rastgele şifre oluştur" + }, + "expiration": "Son kullanma süresi", + "no-expiration": "Süresiz", + "delays": { + "1h": "1 saat", + "1d": "1 gün", + "1w": "1 hafta", + "1m": "1 ay" + }, + "delete-after-reading": { + "label": "Okunduktan sonra sil", + "description": "Not okunduktan sonra sil" + }, + "attach-files": "Dosya ekle", + "drop-files": { + "title": "Dosyaları buraya bırakın", + "description": "Notunuza eklemek için dosyaları buraya sürükleyin" + }, + "create": "Not oluştur", + "creating": "Not oluşturuluyor..." + }, + "success": { + "title": "Not başarıyla oluşturuldu", + "description": "Notunuz oluşturuldu. Aşağıdaki bağlantıyı kullanarak paylaşabilirsiniz.", + "with-deletion": "Not okunduktan sonra silinecek.", + "copy-link": "Bağlantıyı kopyala", + "copy-success": "Bağlantı kopyalandı" + }, + "qr-code": { + "title": "Bu notu mobilde paylaş", + "description": "Bu QR kodunu tarayarak notu mobil cihazınızda görüntüleyebilirsiniz. QR kodunu başkalarıyla paylaşmak için dışa aktarabilirsiniz.", + "export": "QR kodunu dışa aktar", + "download-svg": "SVG olarak indir", + "download-png": "PNG olarak indir", + "download-success": "QR kod indirildi", + "copy-svg": "SVG kodunu kopyala", + "copy-success": "SVG panoya kopyalandı" + } + }, + "view": { + "loading": "Not yükleniyor...", + "note-content": "Not içeriği", + "assets": { + "download": "İndir", + "download-all": "Tüm dosyaları indir", + "heading-multiple": "Bu nota eklenmiş {{ count }} dosya var", + "heading-single": "Bu nota eklenmiş 1 dosya var" + }, + "request-password": { + "description": "Bu not şifre korumalıdır. Lütfen kilidini açmak için şifreyi girin.", + "form": { + "label": "Şifre", + "placeholder": "Not şifresi...", + "unlock-button": "Notun kilidini aç", + "invalid": "Girdiğiniz şifre geçersiz veya not URL’si hatalı." + } + }, + "warn-for-note-deletion": { + "title": "Bu not silinecek", + "description": "Bu not okunduktan sonra silinecek şekilde yapılandırılmıştır. Not görüntülendikten sonra kalıcı olarak silinecek ve geri alınamayacaktır.", + "confirm": "Anladım, notu göster" + }, + "error": { + "invalid-url": { + "title": "Geçersiz not URL’si", + "description": "Bu not URL’si geçersiz. Doğru URL’yi kullandığınızdan emin olun." + }, + "rate-limit": { + "title": "Hız sınırı aşıldı", + "description": "Notları almak için hız sınırını aştınız. Lütfen daha sonra tekrar deneyin." + }, + "unauthorized": { + "title": "Yetkisiz Erişim", + "description": "Bu not özeldir. Görüntülemek için giriş yapmanız gerekiyor.", + "button": "Giriş yap" + }, + "note-not-found": { + "title": "Not bulunamadı", + "description": "Bu not mevcut değil, süresi dolmuş veya silinmiş." + }, + "fetch-error": { + "title": "Bir hata oluştu", + "description": "Not alınırken bir hata oluştu. Lütfen daha sonra tekrar deneyin." + }, + "decryption": { + "title": "Bir hata oluştu", + "description": "Notun şifresi çözülürken bir hata oluştu. URL geçersiz olabilir." + } + } + }, + "copy": { + "label": "Panoya kopyala", + "success": "Kopyalandı!" + } +} diff --git a/enclosed/packages/app-client/src/locales/vi.json b/enclosed/packages/app-client/src/locales/vi.json new file mode 100644 index 00000000..b5b714b0 --- /dev/null +++ b/enclosed/packages/app-client/src/locales/vi.json @@ -0,0 +1,151 @@ +{ + "app": { + "title": "Enclosed", + "description": "Gửi ghi chú riêng tư và an toàn" + }, + "insecureContextWarning": { + "description": "Kết nối của bạn không an toàn, ứng dụng phải được phục vụ qua HTTPS để hoạt động đúng. Bạn sẽ không thể tạo hoặc xem ghi chú.", + "learn-more": "Tìm hiểu thêm" + }, + "navbar": { + "new-note": "Ghi chú mới", + "github": "GitHub", + "language": "Ngôn ngữ", + "theme": { + "theme": "Chủ đề", + "light-mode": "Chế độ sáng", + "dark-mode": "Chế độ tối", + "system-mode": "Hệ thống" + }, + "settings": { + "documentation": "Tài liệu", + "cli": "Đính kèm CLI", + "support": "Hỗ trợ Đính kèm", + "report-bug": "Báo cáo lỗi", + "logout": "Đăng xuất", + "contribute-to-i18n": "Đóng góp vào i18n" + } + }, + "footer": { + "crafted-by": "Được tạo bởi", + "source-code": "Mã nguồn có sẵn trên", + "github": "GitHub", + "version": "Phiên bản" + }, + "login": { + "title": "Đăng nhập vào Đính kèm", + "description": "Đây là một phiên bản riêng tư của Đính kèm. Nhập thông tin đăng nhập của bạn để có thể tạo ghi chú.", + "email": "Email", + "password": "Mật khẩu", + "submit": "Đăng nhập", + "errors": { + "invalid-credentials": "Email hoặc mật khẩu không hợp lệ.", + "unknown": "Đã xảy ra lỗi không xác định. Vui lòng thử lại sau." + }, + "footer": [ + "Bạn chưa có tài khoản?", + "Liên hệ với chủ sở hữu của phiên bản." + ] + }, + "create": { + "errors": { + "empty-note": "Vui lòng nhập nội dung ghi chú hoặc đính kèm tệp.", + "rate-limit": "Bạn đã vượt quá giới hạn tốc độ cho việc tạo ghi chú. Vui lòng thử lại sau.", + "too-large": "Nội dung ghi chú và tệp đính kèm quá lớn. Vui lòng giảm kích thước và thử lại.", + "unauthorized": "Bạn không được phép tạo ghi chú. Vui lòng đăng nhập và thử lại.", + "api-not-found": "Không tìm thấy điểm cuối API. Máy chủ có thể chưa được cấu hình đúng.", + "unknown": "Đã xảy ra lỗi khi tạo ghi chú, vui lòng thử lại." + }, + "share": { + "button": "Chia sẻ ghi chú", + "title": "Ghi chú được chia sẻ", + "description": "Đây là một ghi chú được chia sẻ với bạn." + }, + "settings": { + "placeholder": "Nhập ghi chú của bạn ở đây...", + "password": { + "label": "Mật khẩu ghi chú", + "placeholder": "Mật khẩu..." + }, + "expiration": "Thời hạn hết hạn", + "delays": { + "1h": "1 giờ", + "1d": "1 ngày", + "1w": "1 tuần", + "1m": "1 tháng" + }, + "delete-after-reading": { + "label": "Xóa sau khi đọc", + "description": "Xóa ghi chú sau khi đọc" + }, + "attach-files": "Đính kèm tệp", + "drop-files": { + "title": "Thả tệp vào đây", + "description": "Kéo thả tệp vào đây để đính kèm chúng vào ghi chú" + }, + "create": "Tạo ghi chú", + "creating": "Đang tạo ghi chú..." + }, + "success": { + "title": "Ghi chú đã được tạo thành công", + "description": "Ghi chú của bạn đã được tạo. Bây giờ bạn có thể chia sẻ nó bằng liên kết sau.", + "with-deletion": "Ghi chú sẽ bị xóa sau khi đọc.", + "copy-link": "Sao chép liên kết", + "copy-success": "Đã sao chép liên kết" + } + }, + "view": { + "note-content": "Nội dung ghi chú", + "assets": { + "download": "Tải xuống", + "download-all": "Tải xuống tất cả tệp", + "heading-multiple": "{{ count }} tệp đính kèm với ghi chú này", + "heading-single": "1 tệp đính kèm với ghi chú này" + }, + "request-password": { + "description": "Ghi chú này được bảo vệ bằng mật khẩu. Vui lòng nhập mật khẩu để mở khóa nó.", + "form": { + "label": "Mật khẩu", + "placeholder": "Mật khẩu ghi chú...", + "unlock-button": "Mở khóa ghi chú", + "invalid": "Mật khẩu bạn nhập không hợp lệ hoặc URL ghi chú không chính xác." + } + }, + "warn-for-note-deletion": { + "title": "Ghi chú này sẽ bị xóa", + "description": "Ghi chú này đã được cấu hình để xóa sau khi đọc. Sau khi hiển thị ghi chú, nó sẽ bị xóa vĩnh viễn và không thể khôi phục.", + "confirm": "Tôi hiểu, hiển thị ghi chú cho tôi" + }, + "error": { + "invalid-url": { + "title": "URL ghi chú không hợp lệ", + "description": "URL ghi chú này không hợp lệ. Vui lòng đảm bảo bạn đang sử dụng URL đúng." + }, + "rate-limit": { + "title": "Vượt quá giới hạn tốc độ", + "description": "Bạn đã vượt quá giới hạn tốc độ cho việc lấy ghi chú. Vui lòng thử lại sau." + }, + "unauthorized": { + "title": "Không được phép", + "description": "Ghi chú này là riêng tư. Bạn cần đăng nhập để xem nó.", + "button": "Đăng nhập" + }, + "note-not-found": { + "title": "Không tìm thấy ghi chú", + "description": "Ghi chú này không tồn tại, đã hết hạn hoặc đã bị xóa." + }, + "fetch-error": { + "title": "Đã xảy ra lỗi", + "description": "Đã xảy ra lỗi khi lấy ghi chú. Vui lòng thử lại sau." + }, + "decryption": { + "title": "Đã xảy ra lỗi", + "description": "Đã xảy ra lỗi khi giải mã ghi chú. URL có thể không hợp lệ." + } + } + }, + "copy": { + "label": "Sao chép vào clipboard", + "success": "Đã sao chép!" + } +} diff --git a/enclosed/packages/app-client/src/locales/zh-CN.json b/enclosed/packages/app-client/src/locales/zh-CN.json new file mode 100644 index 00000000..07d58116 --- /dev/null +++ b/enclosed/packages/app-client/src/locales/zh-CN.json @@ -0,0 +1,159 @@ +{ + "app": { + "title": "Enclosed", + "description": "发送私密、安全的笔记" + }, + "insecureContextWarning": { + "description": "您的连接不安全,应用必须通过 HTTPS 提供才能正常工作,否则将无法创建或查看笔记。", + "learn-more": "了解更多" + }, + "navbar": { + "new-note": "新建笔记", + "github": "GitHub", + "language": "语言", + "theme": { + "theme": "主题", + "light-mode": "浅色模式", + "dark-mode": "深色模式", + "system-mode": "跟随系统" + }, + "settings": { + "documentation": "文档", + "cli": "Enclosed CLI", + "support": "支持 Enclosed", + "report-bug": "报告错误", + "logout": "登出", + "contribute-to-i18n": "贡献翻译" + } + }, + "footer": { + "crafted-by": "作者:", + "source-code": "源代码:", + "github": "GitHub", + "version": "版本" + }, + "login": { + "title": "登录 Enclosed", + "description": "这是一个私有的 Enclosed 实例,输入您的凭据以创建笔记。", + "email": "电子邮箱", + "password": "密码", + "submit": "登录", + "errors": { + "invalid-credentials": "无效的电子邮箱或密码。", + "unknown": "发生未知错误,稍后再试。" + }, + "footer": ["没有账户?", "联系实例的拥有者。"] + }, + "create": { + "errors": { + "empty-note": "请输入笔记内容或附加文件。", + "rate-limit": "已超出创建笔记的速率限制,请稍后重试。", + "too-large": "笔记内容和附件过大,请减小大小后重试。", + "unauthorized": "您没有创建笔记的权限,请登录后重试。", + "api-not-found": "未找到 API 端点,服务器可能未正确配置。", + "unknown": "创建笔记时发生错误,请重试。" + }, + "share": { + "button": "分享笔记", + "title": "共享笔记", + "description": "有一条分享给您的笔记。" + }, + "settings": { + "placeholder": "在此输入您的笔记内容...", + "password": { + "label": "笔记密码", + "placeholder": "密码..." + }, + "expiration": "过期时间", + "no-expiration": "永不过期", + "delays": { + "1h": "1 小时", + "1d": "1 天", + "1w": "1 周", + "1m": "1 个月" + }, + "delete-after-reading": { + "label": "阅后即焚", + "description": "阅读后删除笔记" + }, + "attach-files": "附加文件", + "drop-files": { + "title": "将文件拖到这里", + "description": "将文件拖放到这里以附加到笔记" + }, + "create": "创建笔记", + "creating": "正在创建笔记..." + }, + "success": { + "title": "笔记创建成功", + "description": "您的笔记已创建,您现在可以使用以下链接分享它。", + "with-deletion": "阅读后笔记将被删除。", + "copy-link": "复制链接", + "copy-success": "链接已复制" + }, + "qr-code": { + "title": "在手机上分享此笔记", + "description": "您可以扫描此二维码以查看笔记,或导出二维码图像以与他人分享。", + "export": "导出二维码", + "download-svg": "下载为 SVG 格式", + "download-png": "下载为 PNG 格式", + "download-success": "二维码已下载", + "copy-svg": "复制 SVG 代码", + "copy-success": "SVG 代码已复制到剪贴板" + } + }, + "view": { + "note-content": "笔记内容", + "assets": { + "download": "下载", + "download-all": "下载所有文件", + "heading-multiple": "{{ count }} 个文件已被附加到此笔记", + "heading-single": "1 个文件已被附加到此笔记" + }, + "request-password": { + "description": "这条笔记受密码保护,请输入密码以解锁。", + "form": { + "label": "密码", + "placeholder": "笔记密码...", + "unlock-button": "解锁笔记", + "invalid": "您输入的密码无效或笔记 URL 不正确。" + } + }, + "warn-for-note-deletion": { + "title": "此笔记将被删除", + "description": "此笔记已配置为阅后即焚。显示笔记后,它将被永久删除且无法恢复。", + "confirm": "已知晓,显示笔记" + }, + "error": { + "invalid-url": { + "title": "无效的笔记 URL", + "description": "此笔记 URL 无效,请确保您使用的是正确的 URL。" + }, + "rate-limit": { + "title": "超出速率限制", + "description": "已超出获取笔记的速率限制,请稍后再试。" + }, + "unauthorized": { + "title": "未授权", + "description": "此笔记是私密的,需要登录才能查看。", + "button": "登录" + }, + "note-not-found": { + "title": "未找到笔记", + "description": "此笔记不存在、已过期或已被删除。" + }, + "fetch-error": { + "title": "发生错误", + "description": "获取笔记时发生错误,请稍后再试。" + }, + "decryption": { + "title": "发生错误", + "description": "解密笔记时发生错误,URL 可能无效。" + } + } + }, + "copy": { + "label": "复制到剪贴板", + "success": "已复制!" + } +} diff --git a/enclosed/packages/app-client/src/locales/zh-TW.json b/enclosed/packages/app-client/src/locales/zh-TW.json new file mode 100644 index 00000000..125bcd7d --- /dev/null +++ b/enclosed/packages/app-client/src/locales/zh-TW.json @@ -0,0 +1,171 @@ +{ + "app": { + "title": "Enclosed", + "description": "傳送私密且安全的筆記" + }, + "insecureContextWarning": { + "description": "您的連線並不安全,應用程式必須透過 HTTPS 提供服務才能正常運作。您將無法創建或查看筆記。", + "learn-more": "了解更多" + }, + "navbar": { + "new-note": "新筆記", + "github": "GitHub", + "language": "語言", + "github-repository": "GitHub 倉庫", + "documentation": "文件", + "change-theme": "更改主題", + "menu-icon": "選單圖示", + "change-language": "更改語言", + "theme": { + "theme": "主題", + "light-mode": "亮色模式", + "dark-mode": "暗色模式", + "system-mode": "系統" + }, + "settings": { + "documentation": "文件", + "cli": "Enclosed CLI", + "support": "支持 Enclosed", + "report-bug": "報告錯誤", + "logout": "登出", + "contribute-to-i18n": "貢獻國際化語言" + } + }, + "footer": { + "crafted-by": "由", + "source-code": "源碼可在", + "github": "GitHub", + "version": "版本" + }, + "login": { + "title": "登入 Enclosed", + "description": "這是一個私人實例的 Enclosed。請輸入您的憑證以創建筆記。", + "email": "電子郵件", + "password": "密碼", + "submit": "登入", + "errors": { + "invalid-credentials": "無效的電子郵件或密碼。", + "unknown": "發生未知錯誤。請稍後再試。" + }, + "footer": [ + "沒有帳戶嗎?", + "聯繫網站擁有者。" + ] + }, + "create": { + "errors": { + "empty-note": "請輸入筆記內容或附加檔案。", + "rate-limit": "您已超過創建筆記的速率限制。請稍後再試。", + "too-large": "筆記內容和附件過大。請減少大小後再試。", + "unauthorized": "您沒有權限創建筆記。請登入後再試。", + "api-not-found": "API 端點未找到。伺服器可能未正確配置。", + "unknown": "創建筆記時發生錯誤,請再試一次。" + }, + "share": { + "button": "分享筆記", + "title": "共享筆記", + "description": "這裡有一個與您共享的筆記。" + }, + "settings": { + "placeholder": "在此輸入您的筆記...", + "password": { + "label": "筆記密碼", + "placeholder": "密碼...", + "hide-password": "隱藏密碼", + "show-password": "顯示密碼", + "generate-random-password": "生成隨機密碼" + }, + "expiration": "到期延遲", + "no-expiration": "筆記永不過期", + "delays": { + "1h": "1小時", + "1d": "1天", + "1w": "1週", + "1m": "1個月" + }, + "delete-after-reading": { + "label": "閱讀後刪除", + "description": "閱讀後刪除筆記" + }, + "attach-files": "附加檔案", + "drop-files": { + "title": "在此拖放檔案", + "description": "將檔案拖放到此處以附加到筆記" + }, + "create": "創建筆記", + "creating": "創建筆記中..." + }, + "success": { + "title": "筆記創建成功", + "description": "您的筆記已創建。您現在可以使用以下連結分享它。", + "with-deletion": "筆記將在閱讀後刪除。", + "copy-link": "複製連結", + "copy-success": "連結已複製" + }, + "qr-code": { + "title": "在行動裝置上分享此筆記", + "description": "您可以掃描此 QR 碼在行動裝置上查看筆記。您也可以導出 QR 碼圖片與他人分享。", + "export": "導出 QR 碼", + "download-svg": "以 SVG 下載", + "download-png": "以 PNG 下載", + "download-success": "QR 碼已下載", + "copy-svg": "複製 SVG 碼", + "copy-success": "SVG 已複製到剪貼板" + } + }, + "view": { + "loading": "載入筆記中...", + "note-content": "筆記內容", + "assets": { + "download": "下載", + "download-all": "全部下載", + "heading-multiple": "此筆記附有 {{ count }} 個檔案", + "heading-single": "此筆記附有 1 個檔案" + }, + "request-password": { + "description": "此筆記已加密。請輸入密碼以解鎖。", + "form": { + "label": "密碼", + "placeholder": "筆記密碼...", + "unlock-button": "解鎖筆記", + "invalid": "您輸入的密碼無效或筆記連結不正確。" + } + }, + "warn-for-note-deletion": { + "title": "此筆記將被刪除", + "description": "此筆記已配置為閱讀後刪除。顯示筆記後,它將被永久刪除且無法恢復。", + "confirm": "我了解,顯示筆記" + }, + "error": { + "invalid-url": { + "title": "無效的筆記 URL", + "description": "此筆記連結無效。請確保您使用正確的連結。" + }, + "rate-limit": { + "title": "超過速率限制", + "description": "您已超過獲取筆記的速率限制。請稍後再試。" + }, + "unauthorized": { + "title": "未授權", + "description": "此筆記是私人的。您需要登入才能查看。", + "button": "登入" + }, + "note-not-found": { + "title": "筆記未找到", + "description": "此筆記不存在,已過期或已被刪除。" + }, + "fetch-error": { + "title": "發生錯誤", + "description": "獲取筆記時發生錯誤。請稍後再試。" + }, + "decryption": { + "title": "發生錯誤", + "description": "解密筆記時發生錯誤。連結可能無效。" + } + } + }, + "copy": { + "label": "複製到剪貼板", + "success": "已複製!" + } +} diff --git a/enclosed/packages/app-client/src/modules/auth/auth.models.ts b/enclosed/packages/app-client/src/modules/auth/auth.models.ts new file mode 100644 index 00000000..b222a79c --- /dev/null +++ b/enclosed/packages/app-client/src/modules/auth/auth.models.ts @@ -0,0 +1,12 @@ +export { + isAccessTokenExpired, +}; + +function isAccessTokenExpired({ accessToken }: { accessToken: string }) { + try { + const token = JSON.parse(atob(accessToken.split('.')[1])); + return token.exp < Date.now() / 1000; + } catch { + return true; + } +} diff --git a/enclosed/packages/app-client/src/modules/auth/auth.services.ts b/enclosed/packages/app-client/src/modules/auth/auth.services.ts new file mode 100644 index 00000000..712f5968 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/auth/auth.services.ts @@ -0,0 +1,16 @@ +import { apiClient } from '../shared/http/http-client'; + +export { login }; + +async function login({ email, password }: { email: string; password: string }) { + const { accessToken } = await apiClient<{ accessToken: string }>({ + path: 'api/auth/login', + method: 'POST', + body: { + email, + password, + }, + }); + + return { accessToken }; +} diff --git a/enclosed/packages/app-client/src/modules/auth/auth.store.ts b/enclosed/packages/app-client/src/modules/auth/auth.store.ts new file mode 100644 index 00000000..96af3315 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/auth/auth.store.ts @@ -0,0 +1,48 @@ +import { makePersisted } from '@solid-primitives/storage'; +import { createRoot, createSignal } from 'solid-js'; +import { createHook } from '../shared/hooks/hooks'; +import { isAccessTokenExpired } from './auth.models'; + +export const authStore = createRoot(() => { + const [getAccessToken, setAccessTokenValue] = makePersisted(createSignal(null), { name: 'enclosed_access_token', storage: localStorage }); + const onAuthChangeHook = createHook<{ isAuthenticated: boolean }>(); + const [getRedirectUrl, setRedirectUrl] = makePersisted(createSignal(null), { name: 'enclosed_redirect_url', storage: localStorage }); + + const getIsAuthenticated = () => { + const accessToken = getAccessToken(); + + if (!accessToken) { + return false; + } + + const isExpired = isAccessTokenExpired({ accessToken }); + + return !isExpired; + }; + + const setAccessToken = async ({ accessToken }: { accessToken: string }) => { + setAccessTokenValue(accessToken); + await onAuthChangeHook.trigger({ isAuthenticated: true }); + }; + + const clearAccessToken = async () => { + setAccessTokenValue(null); + await onAuthChangeHook.trigger({ isAuthenticated: false }); + }; + + return { + setAccessToken, + getAccessToken, + clearAccessToken, + getIsAuthenticated, + getRedirectUrl, + setRedirectUrl, + + async logout() { + await clearAccessToken(); + window.location.href = '/login'; + }, + + onAuthChange: onAuthChangeHook.on, + }; +}); diff --git a/enclosed/packages/app-client/src/modules/auth/pages/login.page.tsx b/enclosed/packages/app-client/src/modules/auth/pages/login.page.tsx new file mode 100644 index 00000000..88d16da9 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/auth/pages/login.page.tsx @@ -0,0 +1,174 @@ +import { getConfig } from '@/modules/config/config.provider'; +import { useI18n } from '@/modules/i18n/i18n.provider'; +import { isHttpErrorWithStatusCode } from '@/modules/shared/http/http-errors'; +import { Alert, AlertDescription } from '@/modules/ui/components/alert'; +import { Button } from '@/modules/ui/components/button'; +import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield'; +import { safely } from '@corentinth/chisels'; +import { useNavigate } from '@solidjs/router'; +import { castArray, sample } from 'lodash-es'; +import { type Component, createSignal, onMount, Show } from 'solid-js'; +import { login } from '../auth.services'; +import { authStore } from '../auth.store'; + +const quotations = [ + { + text: 'It\'s so secret, even I forgot what I wrote.', + author: 'Anonymous', + }, + { + text: 'If this note self-destructs, you probably typed something important.', + author: 'The Server, probably', + }, + { + text: 'Why write a love letter when you can send an encrypted one?', + author: 'Cupid', + }, + { + text: 'Your secrets are safe. Seriously, I’m not even able to peek.', + author: 'The Database', + }, + { + text: 'Even Sherlock can’t crack this code.', + author: 'Dr. Watson, probably', + }, + { + text: 'Writing something you’ll regret? At least it’ll disappear.', + author: 'The Note Timer', + }, +]; + +export const LoginPage: Component = () => { + const randomQuotation = sample(quotations)!; + const [getError, setError] = createSignal<{ message: string; details?: string } | null>(null); + const [getEmail, setEmail] = createSignal(''); + const [getPassword, setPassword] = createSignal(''); + const { t } = useI18n(); + + const config = getConfig(); + const navigate = useNavigate(); + + onMount(() => { + if (!config.isAuthenticationRequired || authStore.getIsAuthenticated()) { + navigate('/'); + } + }); + + const onSubmit = async () => { + const [loginResponse, error] = await safely(login({ + email: getEmail(), + password: getPassword(), + })); + + if (isHttpErrorWithStatusCode({ error, statusCode: 401 })) { + setError({ message: t('login.errors.invalid-credentials') }); + return; + } + + if (error) { + setError({ message: t('login.errors.unknown') }); + return; + } + + const { accessToken } = loginResponse; + + authStore.setAccessToken({ accessToken }); + window.location.href = authStore.getRedirectUrl() ?? '/'; + }; + + return ( +
+ + + +
+
+

+ {t('login.title')} +

+
+ {t('login.description')} +
+ +
{ + e.preventDefault(); + onSubmit(); + }} + > + + + {t('login.email')} + + { + setEmail(e.currentTarget.value); + setError(null); + }} + value={getEmail()} + /> + + + + + {t('login.password')} + + { + setPassword(e.currentTarget.value); + setError(null); + }} + value={getPassword()} + /> + + + + +

+ {castArray(t('login.footer')).map(text => (

{text}
))} +

+ + + {error => ( + + + {error().message} + + + )} + + +
+
+
+
+ ); +}; diff --git a/enclosed/packages/app-client/src/modules/config/config.constants.ts b/enclosed/packages/app-client/src/modules/config/config.constants.ts new file mode 100644 index 00000000..37ca714f --- /dev/null +++ b/enclosed/packages/app-client/src/modules/config/config.constants.ts @@ -0,0 +1,12 @@ +import type { Config } from './config.types'; + +export const buildTimeConfig: Config = { + baseApiUrl: import.meta.env.VITE_BASE_API_URL ?? '/', + documentationBaseUrl: import.meta.env.VITE_DOCUMENTATION_BASE_URL ?? 'https://devtools.khulnasoft.com/enclosed-docs', + enclosedVersion: import.meta.env.VITE_ENCLOSED_VERSION ?? '0.0.0', + isAuthenticationRequired: import.meta.env.VITE_IS_AUTHENTICATION_REQUIRED === 'true', + defaultDeleteNoteAfterReading: import.meta.env.VITE_DEFAULT_DELETE_NOTE_AFTER_READING === 'true', + defaultNoteTtlSeconds: Number(import.meta.env.VITE_DEFAULT_NOTE_TTL_SECONDS ?? 3600), + defaultNoteNoExpiration: import.meta.env.VITE_DEFAULT_NOTE_NO_EXPIRATION === 'true', + isSettingNoExpirationAllowed: import.meta.env.VITE_IS_SETTING_NO_EXPIRATION_ALLOWED === 'true', +}; diff --git a/enclosed/packages/app-client/src/modules/config/config.provider.tsx b/enclosed/packages/app-client/src/modules/config/config.provider.tsx new file mode 100644 index 00000000..f637b899 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/config/config.provider.tsx @@ -0,0 +1,18 @@ +import type { Config } from './config.types'; +import { get } from 'lodash-es'; +import { buildTimeConfig } from './config.constants'; + +export { + getConfig, +}; + +function getConfig(): Config { + const runtimeConfig: Partial = get(window, '__CONFIG__', {}); + + const config: Config = { + ...buildTimeConfig, + ...runtimeConfig, + }; + + return config; +} diff --git a/enclosed/packages/app-client/src/modules/config/config.types.ts b/enclosed/packages/app-client/src/modules/config/config.types.ts new file mode 100644 index 00000000..e65a33a2 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/config/config.types.ts @@ -0,0 +1,10 @@ +export type Config = { + baseApiUrl: string; + documentationBaseUrl: string; + isAuthenticationRequired: boolean; + enclosedVersion: string; + defaultDeleteNoteAfterReading: boolean; + defaultNoteTtlSeconds: number; + isSettingNoExpirationAllowed: boolean; + defaultNoteNoExpiration: boolean; +}; diff --git a/enclosed/packages/app-client/src/modules/docs/docs.models.test.ts b/enclosed/packages/app-client/src/modules/docs/docs.models.test.ts new file mode 100644 index 00000000..28e5648f --- /dev/null +++ b/enclosed/packages/app-client/src/modules/docs/docs.models.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'vitest'; +import { buildDocUrl } from './docs.models'; + +describe('docs models', () => { + describe('buildDocUrl', () => { + test('it creates a url to the documentation', () => { + expect( + buildDocUrl({ + path: '/test', + + baseUrl: 'https://devtools.khulnasoft.com/enclosed-docs', + }), + ).to.eql('https://devtools.khulnasoft.com/enclosed-docs/test'); + }); + + test('it uses the default base url', () => { + expect(buildDocUrl({ path: '/test' })).to.eql('https://devtools.khulnasoft.com/enclosed-docs/test'); + }); + + test('it handles clashing slashes', () => { + expect( + buildDocUrl({ + path: '/test', + baseUrl: 'https://devtools.khulnasoft.com/enclosed-docs/', + }), + ).to.eql('https://devtools.khulnasoft.com/enclosed-docs/test'); + }); + }); +}); diff --git a/enclosed/packages/app-client/src/modules/docs/docs.models.ts b/enclosed/packages/app-client/src/modules/docs/docs.models.ts new file mode 100644 index 00000000..dd8f9923 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/docs/docs.models.ts @@ -0,0 +1,16 @@ +import { buildTimeConfig } from '../config/config.constants'; +import { joinUrlParts } from '../shared/http/http-client.models'; + +export { buildDocUrl }; + +function buildDocUrl({ + path, + baseUrl = buildTimeConfig.documentationBaseUrl, +}: { + path: string; + baseUrl?: string; +}): string { + const url = joinUrlParts(baseUrl, path); + + return url; +} diff --git a/enclosed/packages/app-client/src/modules/files/files.models.test.ts b/enclosed/packages/app-client/src/modules/files/files.models.test.ts new file mode 100644 index 00000000..b3665601 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/files/files.models.test.ts @@ -0,0 +1,82 @@ +import { icons as tablerIconSet } from '@iconify-json/tabler'; +import { values } from 'lodash-es'; +import { describe, expect, test } from 'vitest'; +import { getFileIcon, iconByFileType } from './files.models'; + +describe('files models', () => { + describe('iconByFileType', () => { + const icons = values(iconByFileType); + + test('they must at least have the default icon', () => { + expect(iconByFileType['*']).toBeDefined(); + }); + + test('all the icons should be from tabler icon set', () => { + for (const icon of icons) { + expect(icon).to.match(/^i-tabler-/, `Icon ${icon} is not from tabler icon set`); + } + }); + + test('icons should not contain any spaces', () => { + for (const icon of icons) { + expect(icon).not.to.match(/\s/, `Icon ${icon} contains spaces`); + } + }); + + test('the icons used for showing file types should exists with current iconify configuration', () => { + for (const icon of icons) { + const iconName = icon.replace('i-tabler-', ''); + const iconData = tablerIconSet.icons[iconName] ?? tablerIconSet.aliases?.[iconName]; + + expect(iconData).to.not.eql(undefined, `Icon ${icon} does not exist in tabler icon set`); + } + }); + }); + + describe('getFileIcon', () => { + test('a file icon is selected based on the file type', () => { + const file = new File([''], 'test.txt', { type: 'text/plain' }); + const iconsMap = { + '*': 'i-tabler-file', + 'text/plain': 'i-tabler-file-text', + }; + const icon = getFileIcon({ file, iconsMap }); + + expect(icon).to.eql('i-tabler-file-text'); + }); + + test('if a file type is not associated with an icon, the default icon is used', () => { + const file = new File([''], 'test.txt', { type: 'text/html' }); + const iconsMap = { + '*': 'i-tabler-file', + 'text/plain': 'i-tabler-file-text', + }; + const icon = getFileIcon({ file, iconsMap }); + + expect(icon).to.eql('i-tabler-file'); + }); + + test('a file icon can be selected based on the file type group', () => { + const file = new File([''], 'test.html', { type: 'text/html' }); + const iconsMap = { + '*': 'i-tabler-file', + 'text': 'i-tabler-file-text', + }; + const icon = getFileIcon({ file, iconsMap }); + + expect(icon).to.eql('i-tabler-file-text'); + }); + + test('when an icon is defined for both the whole type and the group type, the file type icon is used', () => { + const file = new File([''], 'test.html', { type: 'text/html' }); + const iconsMap = { + '*': 'i-tabler-file', + 'text': 'i-tabler-file-text', + 'text/html': 'i-tabler-file-type-html', + }; + const icon = getFileIcon({ file, iconsMap }); + + expect(icon).to.eql('i-tabler-file-type-html'); + }); + }); +}); diff --git a/enclosed/packages/app-client/src/modules/files/files.models.ts b/enclosed/packages/app-client/src/modules/files/files.models.ts new file mode 100644 index 00000000..514aa09b --- /dev/null +++ b/enclosed/packages/app-client/src/modules/files/files.models.ts @@ -0,0 +1,42 @@ +export { getFileIcon }; + +// Available icons : +// i-tabler-file-3d i-tabler-file-ai i-tabler-file-alert i-tabler-file-analytics i-tabler-file-arrow-left i-tabler-file-arrow-right i-tabler-file-barcode i-tabler-file-bitcoin i-tabler-file-broken i-tabler-file-certificate i-tabler-file-chart i-tabler-file-check i-tabler-file-code i-tabler-file-code-2 i-tabler-file-cv i-tabler-file-database i-tabler-file-delta i-tabler-file-description i-tabler-file-diff i-tabler-file-digit i-tabler-file-dislike i-tabler-file-dollar i-tabler-file-dots i-tabler-file-download i-tabler-file-euro i-tabler-file-excel i-tabler-file-export i-tabler-file-filled i-tabler-file-function i-tabler-file-horizontal i-tabler-file-import i-tabler-file-infinity i-tabler-file-info i-tabler-file-invoice i-tabler-file-isr i-tabler-file-lambda i-tabler-file-like i-tabler-file-minus i-tabler-file-music i-tabler-file-neutral i-tabler-file-off i-tabler-file-orientation i-tabler-file-pencil i-tabler-file-percent i-tabler-file-phone i-tabler-file-plus i-tabler-file-power i-tabler-file-report i-tabler-file-rss i-tabler-file-sad i-tabler-file-scissors i-tabler-file-search i-tabler-file-settings i-tabler-file-shredder i-tabler-file-signal i-tabler-file-smile i-tabler-file-spreadsheet i-tabler-file-stack i-tabler-file-star i-tabler-file-symlink i-tabler-file-text i-tabler-file-text-ai i-tabler-file-time i-tabler-file-type-bmp i-tabler-file-type-css i-tabler-file-type-csv i-tabler-file-type-doc i-tabler-file-type-docx i-tabler-file-type-html i-tabler-file-type-jpg i-tabler-file-type-js i-tabler-file-type-jsx i-tabler-file-type-pdf i-tabler-file-type-php i-tabler-file-type-png i-tabler-file-type-ppt i-tabler-file-type-rs i-tabler-file-type-sql i-tabler-file-type-svg i-tabler-file-type-ts i-tabler-file-type-tsx i-tabler-file-type-txt i-tabler-file-type-vue i-tabler-file-type-xls i-tabler-file-type-xml i-tabler-file-type-zip i-tabler-file-typography i-tabler-file-unknown i-tabler-file-upload i-tabler-file-vector i-tabler-file-word i-tabler-file-x i-tabler-file-x-filled i-tabler-file-zip + +export const iconByFileType = { + '*': 'i-tabler-file', + 'image': 'i-tabler-photo', + 'video': 'i-tabler-video', + 'audio': 'i-tabler-file-music', + 'application': 'i-tabler-file-code', + 'application/pdf': 'i-tabler-file-type-pdf', + 'application/zip': 'i-tabler-file-zip', + 'application/vnd.ms-excel': 'i-tabler-file-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'i-tabler-file-excel', + 'application/msword': 'i-tabler-file-word', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'i-tabler-file-word', + 'application/json': 'i-tabler-file-code', + 'application/xml': 'i-tabler-file-code', + 'application/javascript': 'i-tabler-file-type-js', + 'application/typescript': 'i-tabler-file-type-ts', + 'application/vnd.ms-powerpoint': 'i-tabler-file-type-ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'i-tabler-file-type-ppt', + 'text/plain': 'i-tabler-file-text', + 'text/html': 'i-tabler-file-type-html', + 'text/css': 'i-tabler-file-type-css', + 'text/csv': 'i-tabler-file-type-csv', + 'text/xml': 'i-tabler-file-type-xml', + 'text/javascript': 'i-tabler-file-type-js', + 'text/typescript': 'i-tabler-file-type-ts', +}; + +type FileTypes = keyof typeof iconByFileType; + +function getFileIcon({ file, iconsMap = iconByFileType }: { file: File; iconsMap?: Record & { '*': string } }): string { + const fileType = file.type; + const fileTypeGroup = fileType?.split('/')[0]; + + const icon = iconsMap[fileType as FileTypes] ?? iconsMap[fileTypeGroup as FileTypes] ?? iconsMap['*']; + + return icon; +} diff --git a/enclosed/packages/app-client/src/modules/i18n/i18n.models.test.ts b/enclosed/packages/app-client/src/modules/i18n/i18n.models.test.ts new file mode 100644 index 00000000..1d409331 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/i18n/i18n.models.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'vitest'; +import { findMatchingLocale } from './i18n.models'; + +describe('i18n models', () => { + describe('findMatchingLocale', () => { + test('preferred regional language to regional language', () => { + const preferredLocales = ['pt-BR'].map(x => new Intl.Locale(x)); + const supportedLocales = ['en', 'pt-BR'].map(x => new Intl.Locale(x)); + const locale = findMatchingLocale({ preferredLocales, supportedLocales }); + + expect(locale).toEqual('pt-BR'); + }); + + test('preferred non-regional language to non-regional language', () => { + const preferredLocales = ['pt'].map(x => new Intl.Locale(x)); + const supportedLocales = ['pt-BR', 'pt'].map(x => new Intl.Locale(x)); + const locale = findMatchingLocale({ preferredLocales, supportedLocales }); + + expect(locale).toEqual('pt'); + }); + + test('preferred regional language to non-regional language', () => { + const preferredLocales = ['en-GB'].map(x => new Intl.Locale(x)); + const supportedLocales = ['pt-BR', 'en'].map(x => new Intl.Locale(x)); + const locale = findMatchingLocale({ preferredLocales, supportedLocales }); + + expect(locale).toEqual('en'); + }); + + test('preferred language with different region to supported language', () => { + const preferredLocales = ['en-CA'].map(x => new Intl.Locale(x)); + const supportedLocales = ['fr-FR', 'en-US'].map(x => new Intl.Locale(x)); + const locale = findMatchingLocale({ preferredLocales, supportedLocales }); + + expect(locale).toEqual('en-US'); + }); + + test('preferred language not in supported locales', () => { + const preferredLocales = ['it-IT'].map(x => new Intl.Locale(x)); + const supportedLocales = ['es-ES', 'de-DE'].map(x => new Intl.Locale(x)); + const locale = findMatchingLocale({ preferredLocales, supportedLocales }); + + expect(locale).toEqual('en'); + }); + + test('empty preferred locales', () => { + const preferredLocales: Intl.Locale[] = []; + const supportedLocales = ['en', 'pt-BR'].map(x => new Intl.Locale(x)); + const locale = findMatchingLocale({ preferredLocales, supportedLocales }); + + expect(locale).toEqual('en'); + }); + + test('empty supported locales', () => { + const preferredLocales = ['en-GB', 'pt-BR'].map(x => new Intl.Locale(x)); + const supportedLocales: Intl.Locale[] = []; + const locale = findMatchingLocale({ preferredLocales, supportedLocales }); + + expect(locale).toEqual('en'); + }); + }); +}); diff --git a/enclosed/packages/app-client/src/modules/i18n/i18n.models.ts b/enclosed/packages/app-client/src/modules/i18n/i18n.models.ts new file mode 100644 index 00000000..3679d646 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/i18n/i18n.models.ts @@ -0,0 +1,22 @@ +import type { Locale } from './i18n.provider'; + +// This tries to get the most preferred language compatible with the supported languages +// It tries to find a supported language by comparing both region and language, if not, then just language +// For example: +// en-GB -> en +// pt-BR -> pt-BR +export function findMatchingLocale({ preferredLocales, supportedLocales }: { preferredLocales: Intl.Locale[]; supportedLocales: Intl.Locale[] }) { + for (const locale of preferredLocales) { + const localeMatchRegion = supportedLocales.find(x => x.baseName === locale.baseName); + + if (localeMatchRegion) { + return localeMatchRegion.baseName as Locale; + } + + const localeMatchLanguage = supportedLocales.find(x => x.language === locale.language); + if (localeMatchLanguage) { + return localeMatchLanguage.baseName as Locale; + } + } + return 'en'; +} diff --git a/enclosed/packages/app-client/src/modules/i18n/i18n.provider.tsx b/enclosed/packages/app-client/src/modules/i18n/i18n.provider.tsx new file mode 100644 index 00000000..2eaf09c3 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/i18n/i18n.provider.tsx @@ -0,0 +1,73 @@ +import type { ParentComponent } from 'solid-js'; +import { locales } from '@/locales/locales'; +import * as i18n from '@solid-primitives/i18n'; +import { makePersisted } from '@solid-primitives/storage'; +import { merge } from 'lodash-es'; +import { createContext, createEffect, createResource, createSignal, Show, useContext } from 'solid-js'; +import defaultDict from '../../locales/en.json'; +import { findMatchingLocale } from './i18n.models'; + +export { + useI18n, +}; +export type { Locale }; + +type Locale = typeof locales[number]['key']; +type RawDictionary = typeof defaultDict; +type Dictionary = i18n.Flatten; + +const I18nContext = createContext<{ + t: i18n.Translator; + getLocale: () => Locale; + setLocale: (locale: Locale) => void; + locales: typeof locales; +} | undefined>(undefined); + +function useI18n() { + const context = useContext(I18nContext); + + if (!context) { + throw new Error('I18n context not found'); + } + + return context; +} + +async function fetchDictionary(locale: Locale): Promise { + const dict: RawDictionary = (await import(`../../locales/${locale}.json`)); + const mergedDict = merge({}, defaultDict, dict); + const flattened = i18n.flatten(mergedDict); + + return flattened; +} + +export const I18nProvider: ParentComponent = (props) => { + const browserLocale = findMatchingLocale({ + preferredLocales: navigator.languages.map(x => new Intl.Locale(x)), + supportedLocales: locales.map(x => new Intl.Locale(x.key)), + }); + const [getLocale, setLocale] = makePersisted(createSignal(browserLocale), { name: 'enclosed_locale', storage: localStorage }); + + const [dict] = createResource(getLocale, fetchDictionary); + + createEffect(() => { + document.documentElement.lang = getLocale(); + }); + + return ( + + {dictLatest => ( + + {props.children} + + )} + + ); +}; diff --git a/enclosed/packages/app-client/src/modules/notes/components/file-uploader.tsx b/enclosed/packages/app-client/src/modules/notes/components/file-uploader.tsx new file mode 100644 index 00000000..ac4d606b --- /dev/null +++ b/enclosed/packages/app-client/src/modules/notes/components/file-uploader.tsx @@ -0,0 +1,108 @@ +import { useI18n } from '@/modules/i18n/i18n.provider'; +import { cn } from '@/modules/shared/style/cn'; +import { Button } from '@/modules/ui/components/button'; +import { type Component, type ComponentProps, createSignal, onCleanup, type ParentComponent, splitProps } from 'solid-js'; + +const DropArea: Component<{ onFilesDrop?: (args: { files: File[] }) => void }> = (props) => { + const [isDragging, setIsDragging] = createSignal(false); + const { t } = useI18n(); + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: DragEvent) => { + if (e.relatedTarget === null) { + setIsDragging(false); + } + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const files = Array.from(e.dataTransfer?.files ?? []); + + if (files.length === 0) { + return; + } + + props.onFilesDrop?.({ files }); + }; + + // Adding global event listeners for drag and drop + document.addEventListener('dragover', handleDragOver); + document.addEventListener('dragleave', handleDragLeave); + document.addEventListener('drop', handleDrop); + + // Cleanup listeners when component unmounts + onCleanup(() => { + document.removeEventListener('dragover', handleDragOver); + document.removeEventListener('dragleave', handleDragLeave); + document.removeEventListener('drop', handleDrop); + }); + + return ( +
+
+
+
+ {t('create.settings.drop-files.title')} +
+
+ {t('create.settings.drop-files.description')} +
+
+
+ ); +}; + +export const FileUploaderButton: ParentComponent<{ + onFileUpload?: (args: { file: File }) => void; + onFilesUpload?: (args: { files: File[] }) => void; + multiple?: boolean; +} & ComponentProps> = (props) => { + const [fileInputRef, setFileInputRef] = createSignal(null); + const [local, rest] = splitProps(props, ['onFileUpload', 'multiple', 'onFilesUpload']); + + const uploadFiles = ({ files }: { files: File[] }) => { + local.onFilesUpload?.({ files }); + + for (const file of files) { + local.onFileUpload?.({ file }); + } + }; + + const onFileChange = (e: Event) => { + const target = e.target as HTMLInputElement; + if (!target.files) { + return; + } + + const files = Array.from(target.files); + + uploadFiles({ files }); + }; + + const onButtonClick = () => { + fileInputRef()?.click(); + }; + + return ( + <> + + + + + ); +}; diff --git a/enclosed/packages/app-client/src/modules/notes/components/note-password-field.tsx b/enclosed/packages/app-client/src/modules/notes/components/note-password-field.tsx new file mode 100644 index 00000000..6c65c90c --- /dev/null +++ b/enclosed/packages/app-client/src/modules/notes/components/note-password-field.tsx @@ -0,0 +1,31 @@ +import { useI18n } from '@/modules/i18n/i18n.provider'; +import { Button } from '@/modules/ui/components/button'; +import { TextField } from '@/modules/ui/components/textfield'; +import { type Component, createSignal } from 'solid-js'; +import { createRandomPassword } from '../notes.models'; + +export const NotePasswordField: Component<{ getPassword: () => string; setPassword: (value: string) => void; dataTestId?: string } > = (props) => { + const [getShowPassword, setShowPassword] = createSignal(false); + const { t } = useI18n(); + + const generateRandomPassword = () => { + const password = createRandomPassword({ length: 16 }); + + setShowPassword(true); + props.setPassword(password); + }; + + return ( +
+ props.setPassword(e.currentTarget.value)} class="border-none shadow-none focus-visible:ring-none" type={getShowPassword() ? 'text' : 'password'} data-test-id={props.dataTestId} /> + + + + +
+ ); +}; diff --git a/enclosed/packages/app-client/src/modules/notes/notes.constants.ts b/enclosed/packages/app-client/src/modules/notes/notes.constants.ts new file mode 100644 index 00000000..ecec20aa --- /dev/null +++ b/enclosed/packages/app-client/src/modules/notes/notes.constants.ts @@ -0,0 +1 @@ +export const NOTE_ID_REGEX = /[0-7][0-9A-HJKMNP-TV-Z]{25}/i; diff --git a/enclosed/packages/app-client/src/modules/notes/notes.context.tsx b/enclosed/packages/app-client/src/modules/notes/notes.context.tsx new file mode 100644 index 00000000..2b0b9086 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/notes/notes.context.tsx @@ -0,0 +1,35 @@ +import type { ParentComponent } from 'solid-js'; +import { createContext, useContext } from 'solid-js'; +import { createHook } from '../shared/hooks/hooks'; + +const noteContext = createContext<{ + triggerResetNoteForm: () => Promise; + onResetNoteForm: (callback: () => Promise | void) => void; + removeResetNoteFormHandler: (callback: () => Promise | void) => void; +}>(); + +export const NoteContextProvider: ParentComponent = (props) => { + const resetNoteFormHook = createHook(); + + return ( + + {props.children} + {' '} + + ); +}; + +export function useNoteContext() { + const context = useContext(noteContext); + + if (!context) { + throw new Error('useNoteContext must be used within a NoteContextProvider'); + } + + return context; +} diff --git a/enclosed/packages/app-client/src/modules/notes/notes.models.ts b/enclosed/packages/app-client/src/modules/notes/notes.models.ts new file mode 100644 index 00000000..b5316a1f --- /dev/null +++ b/enclosed/packages/app-client/src/modules/notes/notes.models.ts @@ -0,0 +1,13 @@ +import { sample, times } from 'lodash-es'; + +export { createRandomPassword }; + +function createRandomPassword({ length = 16 }: { length?: number } = {}): string { + const alphabet = 'abcdefghijklmnopqrstuvwxyz'; + const numbers = '0123456789'; + const specialChars = '!@#$%^&*()_+'; + + const corpus = alphabet + alphabet.toUpperCase() + numbers + specialChars; + + return times(length, () => sample(corpus)).join(''); +} diff --git a/enclosed/packages/app-client/src/modules/notes/notes.services.ts b/enclosed/packages/app-client/src/modules/notes/notes.services.ts new file mode 100644 index 00000000..5e2acd74 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/notes/notes.services.ts @@ -0,0 +1,58 @@ +import { apiClient } from '../shared/http/http-client'; + +export { fetchNoteById, fetchNoteExists, storeNote }; + +async function storeNote({ + payload, + ttlInSeconds, + deleteAfterReading, + encryptionAlgorithm, + serializationFormat, + isPublic, +}: { + payload: string; + ttlInSeconds?: number; + deleteAfterReading: boolean; + encryptionAlgorithm: string; + serializationFormat: string; + isPublic?: boolean; +}) { + const { noteId } = await apiClient<{ noteId: string }>({ + path: '/api/notes', + method: 'POST', + body: { + payload, + ttlInSeconds, + deleteAfterReading, + serializationFormat, + encryptionAlgorithm, + isPublic, + }, + }); + + return { noteId }; +} + +async function fetchNoteById({ noteId }: { noteId: string }) { + const { note } = await apiClient<{ note: { + payload: string; + isPasswordProtected: boolean; + assets: string[]; + serializationFormat: string; + encryptionAlgorithm: string; + }; }>({ + path: `/api/notes/${noteId}`, + method: 'GET', + }); + + return { note }; +} + +async function fetchNoteExists({ noteId }: { noteId: string }) { + const { noteExists } = await apiClient<{ noteExists: boolean }>({ + method: 'GET', + path: `/api/notes/${noteId}/exists`, + }); + + return { noteExists }; +} diff --git a/enclosed/packages/app-client/src/modules/notes/notes.usecases.ts b/enclosed/packages/app-client/src/modules/notes/notes.usecases.ts new file mode 100644 index 00000000..c956a337 --- /dev/null +++ b/enclosed/packages/app-client/src/modules/notes/notes.usecases.ts @@ -0,0 +1,22 @@ +import { createNote, filesToNoteAssets } from '@khulnasoft.com/enclosed-lib'; +import { storeNote } from './notes.services'; + +export { encryptAndCreateNote }; + +async function encryptAndCreateNote(args: { + content: string; + password?: string; + ttlInSeconds?: number; + deleteAfterReading: boolean; + fileAssets: File[]; + isPublic?: boolean; +}) { + return createNote({ + ...args, + storeNote, + clientBaseUrl: window.location.origin, + assets: [ + ...await filesToNoteAssets({ files: args.fileAssets }), + ], + }); +} diff --git a/enclosed/packages/app-client/src/modules/notes/pages/create-note.page.tsx b/enclosed/packages/app-client/src/modules/notes/pages/create-note.page.tsx new file mode 100644 index 00000000..9365307d --- /dev/null +++ b/enclosed/packages/app-client/src/modules/notes/pages/create-note.page.tsx @@ -0,0 +1,404 @@ +import { authStore } from '@/modules/auth/auth.store'; +import { getConfig } from '@/modules/config/config.provider'; +import { getFileIcon } from '@/modules/files/files.models'; +import { useI18n } from '@/modules/i18n/i18n.provider'; +import { svgToBase64Png } from '@/modules/shared/files/convert'; +import { downloadBase64File, downloadSvgFile } from '@/modules/shared/files/download'; +import { isHttpErrorWithCode, isHttpErrorWithStatusCode, isRateLimitError } from '@/modules/shared/http/http-errors'; +import { cn } from '@/modules/shared/style/cn'; +import { CopyButton, useCopy } from '@/modules/shared/utils/copy'; +import { Alert, AlertDescription } from '@/modules/ui/components/alert'; +import { Button } from '@/modules/ui/components/button'; +import { Card, CardHeader } from '@/modules/ui/components/card'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu'; +import { toast } from '@/modules/ui/components/sonner'; +import { SwitchControl, SwitchLabel, SwitchThumb, Switch as SwitchUiComponent } from '@/modules/ui/components/switch'; +import { Tabs, TabsIndicator, TabsList, TabsTrigger } from '@/modules/ui/components/tabs'; +import { TextArea } from '@/modules/ui/components/textarea'; +import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield'; +import { safely } from '@corentinth/chisels'; +import { useNavigate } from '@solidjs/router'; +import { type Component, createSignal, Match, onCleanup, onMount, Show, Switch } from 'solid-js'; +import { renderSVG as renderQrCodeSvg } from 'uqr'; +import { FileUploaderButton } from '../components/file-uploader'; +import { NotePasswordField } from '../components/note-password-field'; +import { useNoteContext } from '../notes.context'; +import { encryptAndCreateNote } from '../notes.usecases'; + +const QrCodeCard: Component<{ noteUrl: string }> = (props) => { + const getNoteUrl = () => props.noteUrl; + const { t } = useI18n(); + const { copy } = useCopy(); + + const downloadSvgQrCode = () => { + downloadSvgFile({ svg: renderQrCodeSvg(getNoteUrl()), fileName: 'note-qr-code.svg' }); + toast.success(t('create.qr-code.download-success')); + }; + + const downloadPngQrCode = async () => { + const base64 = await svgToBase64Png({ svg: renderQrCodeSvg(getNoteUrl()), width: 512, height: 512 }); + downloadBase64File({ base64, fileName: 'note-qr-code.png' }); + toast.success(t('create.qr-code.download-success')); + }; + + const copyQrCodeSvg = () => { + copy({ text: renderQrCodeSvg(getNoteUrl()) }); + toast.success(t('create.qr-code.copy-success')); + }; + + return ( + + +
+
+
+
+
+ +
+
+
+ {t('create.qr-code.title')} +
+ +
+ {t('create.qr-code.description')} +
+
+ +
+ + + + + + + +
+ {t('create.qr-code.download-png')} +
+ + +
+ {t('create.qr-code.download-svg')} +
+ + +
+ {t('create.qr-code.copy-svg')} +
+
+
+
+
+
+ + + ); +}; + +export const CreateNotePage: Component = () => { + const config = getConfig(); + const { t } = useI18n(); + const navigate = useNavigate(); + const { onResetNoteForm, removeResetNoteFormHandler } = useNoteContext(); + + const [getContent, setContent] = createSignal(''); + const [getPassword, setPassword] = createSignal(''); + const [getNoteUrl, setNoteUrl] = createSignal(''); + const [getError, setError] = createSignal<{ message: string; details?: string } | null>(null); + const [getIsNoteCreated, setIsNoteCreated] = createSignal(false); + const [getIsPublic, setIsPublic] = createSignal(true); + const [getTtlInSeconds, setTtlInSeconds] = createSignal(config.defaultNoteTtlSeconds); + const [getDeleteAfterReading, setDeleteAfterReading] = createSignal(config.defaultDeleteNoteAfterReading); + const [getUploadedFiles, setUploadedFiles] = createSignal([]); + const [getIsNoteCreating, setIsNoteCreating] = createSignal(false); + const [getHasNoExpiration, setHasNoExpiration] = createSignal(config.defaultNoteNoExpiration); + + function resetNoteForm() { + setContent(''); + setPassword(''); + setNoteUrl(''); + setError(null); + setIsPublic(true); + setIsNoteCreated(false); + setTtlInSeconds(3600); + setDeleteAfterReading(false); + setUploadedFiles([]); + setIsNoteCreating(false); + } + + onMount(() => { + if (config.isAuthenticationRequired && !authStore.getIsAuthenticated()) { + navigate('/login'); + return; + } + + onResetNoteForm(resetNoteForm); + }); + + onCleanup(() => { + removeResetNoteFormHandler(resetNoteForm); + }); + + const createNote = async () => { + if (!getContent() && getUploadedFiles().length === 0) { + setError({ message: t('create.errors.empty-note') }); + return; + } + + setIsNoteCreating(true); + + const [createdNote, error] = await safely(encryptAndCreateNote({ + content: getContent(), + password: getPassword(), + ttlInSeconds: getHasNoExpiration() ? undefined : getTtlInSeconds(), + deleteAfterReading: getDeleteAfterReading(), + fileAssets: getUploadedFiles(), + isPublic: getIsPublic(), + })); + + setIsNoteCreating(false); + + if (!error) { + const { noteUrl } = createdNote; + + setNoteUrl(noteUrl); + setIsNoteCreated(true); + return; + } + + if (isRateLimitError({ error })) { + setError({ message: t('create.errors.rate-limit') }); + return; + } + + if (isHttpErrorWithCode({ error, code: 'note.payload_too_large' })) { + setError({ message: t('create.errors.too-large') }); + return; + } + + if (isHttpErrorWithCode({ error, code: 'auth.unauthorized' })) { + setError({ message: t('create.errors.unauthorized') }); + return; + } + + if (isHttpErrorWithStatusCode({ error, statusCode: 404 })) { + setError({ message: t('create.errors.api-not-found') }); + return; + } + + setError({ message: t('create.errors.unknown'), details: error.message }); + console.error(error); + }; + + function updateContent(text: string) { + setContent(text); + setError(null); + } + + function updateUploadedFiles(files: File[]) { + setUploadedFiles(prevFiles => [...prevFiles, ...files]); + setError(null); + } + + const getIsShareApiSupported = () => navigator.share !== undefined; + + const shareNote = async () => { + if (!getIsShareApiSupported()) { + return; + } + + try { + await navigator.share({ + title: t('create.share.title'), + text: t('create.share.description'), + url: getNoteUrl(), + }); + } catch (error) { + console.error(error); + } + }; + + return ( +
+ + + +