mirror of
https://github.com/cupcakearmy/cryptgeon.git
synced 2026-06-10 11:16:48 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3e475c16d | |||
| 5ff4d42aa6 | |||
| 9c4fbc30f9 | |||
| f55925de86 | |||
| afbe6aac0f | |||
| e14abeee74 | |||
| f5823fb533 | |||
| 3e3d528d5a | |||
| 84ec85e96f | |||
| 905bce0072 | |||
| e8c3b53e49 | |||
| d40bbd25ea | |||
| 339c3ac445 | |||
| e27915db06 | |||
| d400cfc8e6 | |||
| 198accddeb | |||
| 57001e90a4 |
@@ -86,6 +86,7 @@ of the notes even if it tried to.
|
||||
| `THEME_PAGE_TITLE` | `""` | Custom text the page title |
|
||||
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
|
||||
| `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. |
|
||||
| `THEME_HOME_LINK` | `true` | Show the `/home` link in the footer. Defaults to `true`. |
|
||||
| `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. |
|
||||
| `IMPRINT_HTML` | `""` | Alternative to `IMPRINT_URL`, this can be used to specify the HTML code to show on `/imprint`. Only `IMPRINT_HTML` or `IMPRINT_URL` should be specified, not both. |
|
||||
## Deployment
|
||||
|
||||
@@ -72,6 +72,7 @@ se usa para guardar y recuperar la nota. Después la nota es encriptada con la <
|
||||
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
|
||||
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título |
|
||||
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
|
||||
| `THEME_HOME_LINK` | `true` | Mostrar el enlace `/home` en el pie de página. El valor predeterminado es `true`. |
|
||||
|
||||
## Despliegue
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全
|
||||
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块 |
|
||||
| `THEME_IMAGE` | `""` | 自定义 Logo 图片,你在这里填写的的图片链接必须是可以公开访问的。 |
|
||||
| `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 |
|
||||
| `THEME_HOME_LINK` | `true` | 是否在页脚显示 `/home` 链接。默认为 `true`。 |
|
||||
|
||||
## 部署
|
||||
|
||||
|
||||
@@ -6,3 +6,6 @@ watchexec = "latest"
|
||||
|
||||
[settings]
|
||||
idiomatic_version_file_enable_tools = ["node"]
|
||||
|
||||
[env]
|
||||
SIZE_LIMIT = "100mb"
|
||||
|
||||
Generated
+1
-1
@@ -252,7 +252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptgeon"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"bs62",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cryptgeon"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
authors = ["cupcakearmy <hi@nicco.io>"]
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -70,4 +70,8 @@ lazy_static! {
|
||||
.unwrap_or("true".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
pub static ref THEME_HOME_LINK: bool = std::env::var("THEME_HOME_LINK")
|
||||
.unwrap_or("true".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct Status {
|
||||
pub theme_page_title: String,
|
||||
pub theme_favicon: String,
|
||||
pub theme_new_note_notice: bool,
|
||||
pub theme_home_link: bool,
|
||||
}
|
||||
|
||||
pub async fn get_status() -> (StatusCode, Json<Status>) {
|
||||
@@ -33,6 +34,7 @@ pub async fn get_status() -> (StatusCode, Json<Status>) {
|
||||
imprint_url: config::IMPRINT_URL.to_string(),
|
||||
imprint_html: config::IMPRINT_HTML.to_string(),
|
||||
theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
|
||||
theme_home_link: *config::THEME_HOME_LINK,
|
||||
theme_image: config::THEME_IMAGE.to_string(),
|
||||
theme_text: config::THEME_TEXT.to_string(),
|
||||
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cryptgeon",
|
||||
"version": "2.9.1",
|
||||
"version": "2.9.2",
|
||||
"homepage": "https://github.com/cupcakearmy/cryptgeon",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -120,6 +120,7 @@ export type Status = {
|
||||
theme_favicon: string
|
||||
theme_page_title: string
|
||||
theme_new_note_notice: boolean
|
||||
theme_home_link: boolean
|
||||
}
|
||||
|
||||
async function status() {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
├─ MIT: 13
|
||||
├─ ISC: 2
|
||||
├─ BSD-3-Clause: 1
|
||||
├─ (MPL-2.0 OR Apache-2.0): 1
|
||||
├─ BSD-2-Clause: 1
|
||||
├─ MIT: 324
|
||||
├─ ISC: 84
|
||||
├─ Apache-2.0: 21
|
||||
├─ BlueOak-1.0.0: 14
|
||||
├─ BSD-3-Clause: 6
|
||||
├─ BSD-2-Clause: 4
|
||||
├─ OFL-1.1: 1
|
||||
├─ Python-2.0: 1
|
||||
├─ CC-BY-4.0: 1
|
||||
├─ UNKNOWN: 1
|
||||
├─ MPL-2.0: 1
|
||||
├─ CC-BY-3.0: 1
|
||||
├─ CC0-1.0: 1
|
||||
├─ (MIT AND CC-BY-3.0): 1
|
||||
├─ 0BSD: 1
|
||||
└─ Apache-2.0: 1
|
||||
└─ (MIT OR CC0-1.0): 1
|
||||
|
||||
|
||||
|
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"common": {
|
||||
"note": "poznámka",
|
||||
"file": "soubor",
|
||||
"advanced": "pokročilé",
|
||||
"create": "vytvořit",
|
||||
"loading": "načítání",
|
||||
"mode": "režim",
|
||||
"views": "{n, plural, =0 {zobrazení} =1 {1 zobrazení} other {# zobrazení}}",
|
||||
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {# minutách}}",
|
||||
"max": "max",
|
||||
"share_link": "sdílet odkaz",
|
||||
"copy_clipboard": "zkopírovat do schránky",
|
||||
"copied_to_clipboard": "zkopírováno do schránky",
|
||||
"encrypting": "šifrování",
|
||||
"decrypting": "dešifrování",
|
||||
"uploading": "nahrávání",
|
||||
"downloading": "stahování",
|
||||
"qr_code": "QR kód",
|
||||
"password": "heslo"
|
||||
},
|
||||
"home": {
|
||||
"intro": "Jednoduše posílejte <i>plně šifrované</i> a zabezpečené poznámky nebo soubory jediným kliknutím. Stačí vytvořit poznámku a sdílet odkaz.",
|
||||
"explanation": "Poznámka vyprší a bude zničena po {n}.",
|
||||
"new_note": "Nová poznámka",
|
||||
"new_note_notice": "<b>Dostupnost:</b><br />Poznámka není zaručeně uchována, protože je uložena pouze v paměti RAM. Pokud se paměť zaplní, nejstarší poznámky budou automaticky smazány.<br />(Obvykle to nebývá problém, ale je dobré o tom vědět.)",
|
||||
"errors": {
|
||||
"note_to_big": "Poznámku nelze vytvořit. Je příliš velká.",
|
||||
"note_error": "Poznámku nelze vytvořit. Zkuste to prosím znovu.",
|
||||
"max": "Max: {n}",
|
||||
"empty_content": "Poznámka je prázdná."
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "Poznámka byla vytvořena."
|
||||
},
|
||||
"advanced": {
|
||||
"explanation": "Ve výchozím nastavení se pro každou poznámku generuje bezpečné heslo. Pokud chcete, můžete si nastavit vlastní heslo, které nebude součástí odkazu.",
|
||||
"custom_password": "Vlastní heslo"
|
||||
},
|
||||
"pasting": "Vkládání...",
|
||||
"pasted_files": "Vložené soubory",
|
||||
"remove": "Odstranit"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"not_found": "Poznámka nebyla nalezena nebo již byla smazána.",
|
||||
"decryption_failed": "Špatné heslo. Nelze dešifrovat. Odkaz je pravděpodobně poškozen. Poznámka byla zničena.",
|
||||
"unsupported_type": "Nepodporovaný typ poznámky."
|
||||
},
|
||||
"explanation": "Klikněte níže pro zobrazení a následné smazání poznámky, pokud bylo dosaženo limitu zobrazení.",
|
||||
"show_note": "Zobrazit poznámku",
|
||||
"warning_will_not_see_again": "Tuto poznámku již <b>nebudete</b> moci znovu zobrazit.",
|
||||
"download_all": "Stáhnout vše",
|
||||
"links_found": "Odkazy nalezené v poznámce:"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Vybrané soubory",
|
||||
"no_files_selected": "Žádné soubory nevybrány",
|
||||
"clear": "Vymazat"
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "Standardmäßig wird für jede Notiz ein generiertes, sicheres Passwort verwendet. Alternativ können Sie ein eigenes Kennwort festlegen, welches nicht im Link enthalten ist.",
|
||||
"custom_password": "Benutzerdefiniertes Passwort"
|
||||
}
|
||||
},
|
||||
"pasting": "Einfügen...",
|
||||
"pasted_files": "Eingefügte Dateien",
|
||||
"remove": "Entfernen"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "By default, a securely generated password is used for each note. You can however also choose your own password, which is not included in the link.",
|
||||
"custom_password": "custom password"
|
||||
}
|
||||
},
|
||||
"pasting": "Pasting...",
|
||||
"pasted_files": "Pasted Files",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "Por defecto, se utiliza una contraseña generada de forma segura para cada nota. No obstante, también puede elegir su propia contraseña, la cual no se incluye en el enlace.",
|
||||
"custom_password": "contraseña personalizada"
|
||||
}
|
||||
},
|
||||
"pasting": "Pegando...",
|
||||
"pasted_files": "Archivos pegados",
|
||||
"remove": "Eliminar"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "Par défaut, un mot de passe généré de manière sécurisée est utilisé pour chaque note. Vous pouvez toutefois choisir votre propre mot de passe, qui n'est pas inclus dans le lien.",
|
||||
"custom_password": "mot de passe personnalisé"
|
||||
}
|
||||
},
|
||||
"pasting": "Collage...",
|
||||
"pasted_files": "Fichiers collés",
|
||||
"remove": "Supprimer"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "Per impostazione predefinita, per ogni nota viene utilizzata una password generata in modo sicuro. È tuttavia possibile scegliere la propria password, che non è inclusa nel link.",
|
||||
"custom_password": "password personalizzata"
|
||||
}
|
||||
},
|
||||
"pasting": "Incollando...",
|
||||
"pasted_files": "File incollati",
|
||||
"remove": "Rimuovi"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "デフォルトでは、安全に生成されたパスワードが各ノートに使用されます。しかし、リンクに含まれない独自のパスワードを選択することもできます。",
|
||||
"custom_password": "カスタムパスワード"
|
||||
}
|
||||
},
|
||||
"pasting": "貼り付け中...",
|
||||
"pasted_files": "貼り付けたファイル",
|
||||
"remove": "削除"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"create": "utwórz",
|
||||
"loading": "ładowanie",
|
||||
"mode": "tryb",
|
||||
"views": "{n, plural, =0 {wyświetleń} =1 {1 wyświetlenie} other {# wyświetleń}}",
|
||||
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {# minuty}}",
|
||||
"views": "{n, plural, =0 {wyświetleń} =1 {1 wyświetlenie} other {{n} wyświetleń}}",
|
||||
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {{n} minuty}}",
|
||||
"max": "maks.",
|
||||
"share_link": "link udostępniania",
|
||||
"copy_clipboard": "kopiuj do schowka",
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "Domyślnie dla każdej notatki używane jest bezpiecznie wygenerowane hasło. Możesz jednak wybrać własne hasło, które nie jest uwzględnione w linku.",
|
||||
"custom_password": "własne hasło"
|
||||
}
|
||||
},
|
||||
"pasting": "Wklejanie...",
|
||||
"pasted_files": "Wklejone pliki",
|
||||
"remove": "Usuń"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "По умолчанию для каждой заметки используется безопасно сгенерированный пароль. Однако вы также можете выбрать свой собственный пароль, который не включен в ссылку.",
|
||||
"custom_password": "пользовательский пароль"
|
||||
}
|
||||
},
|
||||
"pasting": "Вставка...",
|
||||
"pasted_files": "Вставленные файлы",
|
||||
"remove": "Удалить"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "預設情況下,每個筆記都會使用安全生成的密碼。您也可以選擇自己的密碼,該密碼不會包含在連結中。",
|
||||
"custom_password": "自定義密碼"
|
||||
}
|
||||
},
|
||||
"pasting": "正在粘貼...",
|
||||
"pasted_files": "粘貼的檔案",
|
||||
"remove": "移除"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。",
|
||||
"custom_password": "自定义密码"
|
||||
}
|
||||
},
|
||||
"pasting": "正在粘贴...",
|
||||
"pasted_files": "粘贴的文件",
|
||||
"remove": "移除"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig tsconfig.json",
|
||||
"licenses": "license-checker --summary > licenses.csv",
|
||||
"licenses": "license-checker-rseidelsohn --summary > licenses.csv",
|
||||
"locale:download": "node scripts/locale.js",
|
||||
"test:prepare": "pnpm run build"
|
||||
},
|
||||
@@ -20,6 +20,7 @@
|
||||
"@zerodevx/svelte-toast": "^0.9.6",
|
||||
"adm-zip": "^0.5.17",
|
||||
"dotenv": "^17.4.2",
|
||||
"license-checker-rseidelsohn": "^5.0.1",
|
||||
"svelte": "^5.55.9",
|
||||
"svelte-check": "^4.4.8",
|
||||
"svelte-intl-precompile": "^0.12.3",
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
import Button from '$lib/ui/Button.svelte'
|
||||
import type { FileDTO } from 'cryptgeon/shared'
|
||||
|
||||
interface Props {
|
||||
files: FileDTO[]
|
||||
}
|
||||
|
||||
let { files = $bindable([]) }: Props = $props()
|
||||
|
||||
let previewUrls: string[] = $state([])
|
||||
|
||||
$effect(() => {
|
||||
const urls = files.map((f) => URL.createObjectURL(new Blob([f.contents], { type: f.type })))
|
||||
previewUrls = urls
|
||||
return () => {
|
||||
for (const url of urls) URL.revokeObjectURL(url)
|
||||
}
|
||||
})
|
||||
|
||||
function remove(index: number) {
|
||||
const removed = files[index]
|
||||
URL.revokeObjectURL(previewUrls[index])
|
||||
files = files.toSpliced(index, 1)
|
||||
}
|
||||
|
||||
function isImage(type: string) {
|
||||
return type.startsWith('image/')
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if files.length > 0}
|
||||
<div class="pasted-files-preview">
|
||||
<h4>{$t('home.pasted_files')}</h4>
|
||||
<div class="files-grid">
|
||||
{#each files as entry, index}
|
||||
<div class="file-preview">
|
||||
{#if isImage(entry.type)}
|
||||
<img src={previewUrls[index]} class="preview-img" alt={entry.name} />
|
||||
{:else}
|
||||
<div class="file-icon">
|
||||
<div class="file-extension">
|
||||
{entry.name.split('.').pop()?.toUpperCase() || entry.type}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="file-name">{entry.name}</div>
|
||||
<div class="file-actions">
|
||||
<Button onclick={() => remove(index)}>
|
||||
{$t('home.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pasted-files-preview {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px dashed #ccc;
|
||||
border-radius: 4px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.pasted-files-preview h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.files-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
background: #f5f5f5;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.file-extension {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
margin-top: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pasted-files-preview {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-img, .file-icon {
|
||||
max-width: 120px;
|
||||
max-height: 120px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.files-grid {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -80,6 +80,15 @@
|
||||
</button>
|
||||
<small> {file.type} - {prettyBytes(file.size)}</small>
|
||||
</div>
|
||||
{#if file.type.startsWith('image/')}
|
||||
{#key file.name}
|
||||
<img
|
||||
src={URL.createObjectURL(new File([file.contents], file.name, { type: file.type }))}
|
||||
alt={file.name}
|
||||
class="preview"
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
{/each}
|
||||
<Button onclick={download}>{$t('show.download_all')}</Button>
|
||||
{/if}
|
||||
@@ -130,4 +139,13 @@
|
||||
margin-bottom: 0.5rem;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 2px solid var(--ui-bg-1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import FileUpload from '$lib/ui/FileUpload.svelte'
|
||||
import Loader from '$lib/ui/Loader.svelte'
|
||||
import MaxSize from '$lib/ui/MaxSize.svelte'
|
||||
import PastedFilesPreview from '$lib/ui/PastedFilesPreview.svelte'
|
||||
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
|
||||
import Switch from '$lib/ui/Switch.svelte'
|
||||
import TextArea from '$lib/ui/TextArea.svelte'
|
||||
@@ -29,6 +30,7 @@
|
||||
let customPassword: string | null = $state(null)
|
||||
let description = $state('')
|
||||
let loading: string | null = $state(null)
|
||||
let isPasting = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (!advanced) {
|
||||
@@ -57,6 +59,62 @@
|
||||
}
|
||||
})
|
||||
|
||||
async function handlePaste(e: ClipboardEvent) {
|
||||
e.preventDefault()
|
||||
const data = e.clipboardData
|
||||
if (!data) return
|
||||
|
||||
const raw: File[] = []
|
||||
|
||||
if (data.files.length) {
|
||||
raw.push(...Array.from(data.files))
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.items.length; i++) {
|
||||
const item = data.items[i]
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) raw.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.length === 0) return
|
||||
|
||||
const seen = new Set<string>()
|
||||
const pasted: File[] = []
|
||||
for (const f of raw) {
|
||||
const key = `${f.name}|${f.size}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
pasted.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
isPasting = true
|
||||
|
||||
const dtos: FileDTO[] = await Promise.all(
|
||||
pasted.map(async (file) => {
|
||||
const ext = file.name.includes('.') ? '' : `.${file.type.split('/')[1] || 'bin'}`
|
||||
const name =
|
||||
file.name || `pasted-file-${Date.now()}-${Math.round(Math.random() * 1000)}${ext}`
|
||||
const renamed = new File([file], name, { type: file.type })
|
||||
return {
|
||||
name: renamed.name,
|
||||
size: renamed.size,
|
||||
type: renamed.type,
|
||||
contents: new Uint8Array(await renamed.arrayBuffer()),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (dtos.length > 0) {
|
||||
if (!isFile) isFile = true
|
||||
files = [...files, ...dtos]
|
||||
}
|
||||
|
||||
isPasting = false
|
||||
}
|
||||
|
||||
class EmptyContentError extends Error {}
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
@@ -110,18 +168,28 @@
|
||||
<p>
|
||||
{@html $status?.theme_text || $t('home.intro')}
|
||||
</p>
|
||||
<form onsubmit={submit}>
|
||||
<form onsubmit={submit} onpaste={handlePaste}>
|
||||
<fieldset disabled={loading !== null}>
|
||||
{#if isFile}
|
||||
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
|
||||
{:else}
|
||||
<TextArea
|
||||
data-testid="text-field"
|
||||
label={$t('common.note')}
|
||||
bind:value={note.contents}
|
||||
placeholder="..."
|
||||
/>
|
||||
{/if}
|
||||
<div class="paste-indicator">
|
||||
{#if isPasting}
|
||||
<div class="pasting-overlay">
|
||||
<div class="pasting-spinner"></div>
|
||||
<span>{$t('home.pasting')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isFile}
|
||||
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
|
||||
{:else}
|
||||
<TextArea
|
||||
data-testid="text-field"
|
||||
label={$t('common.note')}
|
||||
bind:value={note.contents}
|
||||
placeholder="..."
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<PastedFilesPreview bind:files />
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
{#if $status?.allow_files}
|
||||
@@ -180,4 +248,40 @@
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.paste-indicator {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.pasting-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.pasting-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
<footer>
|
||||
<ThemeToggle />
|
||||
<nav>
|
||||
<a href="/">/home</a>
|
||||
{#if $status?.theme_home_link !== false}
|
||||
<a href="/">/home</a>
|
||||
{/if}
|
||||
<a href="/about">/about</a>
|
||||
{#if $status?.imprint_url}
|
||||
<a href={$status.imprint_url} target="_blank" rel="noopener noreferrer">/imprint</a>
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
<header>
|
||||
<a onclick={reset} href="/">
|
||||
{#if $status?.theme_image}
|
||||
{#if $status === null}
|
||||
<!-- waiting for status to load to avoid flashing default logo -->
|
||||
{:else if $status.theme_image}
|
||||
<img alt="logo" src={$status.theme_image} />
|
||||
{:else}
|
||||
<svg
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite'
|
||||
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const port = 3000
|
||||
|
||||
/** @type {import('vite').UserConfig} */
|
||||
const config = {
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port,
|
||||
@@ -14,6 +14,4 @@ const config = {
|
||||
},
|
||||
preview: { port },
|
||||
plugins: [sveltekit(), precompileIntl('locales')],
|
||||
}
|
||||
|
||||
export default config
|
||||
})
|
||||
|
||||
Generated
+1636
-993
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { getFileChecksum } from "../../files";
|
||||
|
||||
const IMG_PATH = "test/assets/image.jpg";
|
||||
|
||||
test.describe("@web", () => {
|
||||
test("paste image", async ({ page, browserName }) => {
|
||||
test.skip(
|
||||
browserName !== "chromium",
|
||||
"DataTransfer.items.add(File) is only supported in Chromium",
|
||||
);
|
||||
const checksum = await getFileChecksum(IMG_PATH);
|
||||
const imgBuffer = await readFile(IMG_PATH);
|
||||
const imgBase64 = imgBuffer.toString("base64");
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("form");
|
||||
|
||||
// Create a paste event with clipboardData containing the image file
|
||||
await page.evaluate(
|
||||
async ({ base64, mimeType }) => {
|
||||
const response = await fetch(`data:${mimeType};base64,${base64}`);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], "image.jpg", { type: mimeType });
|
||||
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
|
||||
const event = new ClipboardEvent("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: dt,
|
||||
});
|
||||
|
||||
document.querySelector("form")?.dispatchEvent(event);
|
||||
},
|
||||
{ base64: imgBase64, mimeType: "image/jpeg" },
|
||||
);
|
||||
|
||||
// Wait for the paste to process and preview to appear
|
||||
await expect(page.locator(".pasted-files-preview")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Create the note
|
||||
await page.locator('button:has-text("create")').click();
|
||||
const link = await page.getByTestId("share-link").inputValue();
|
||||
|
||||
// Navigate to the note and reveal it
|
||||
await page.goto("/");
|
||||
await page.goto(link);
|
||||
await page.getByTestId("show-note-button").click();
|
||||
|
||||
// Verify image preview is visible
|
||||
await expect(page.locator("img.preview")).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Download and verify checksum
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
page.locator('[data-testid="result"] button').first().click(),
|
||||
]);
|
||||
const path = await download.path();
|
||||
if (!path) throw new Error("Download failed");
|
||||
const cs = await getFileChecksum(path);
|
||||
expect(cs).toBe(checksum);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user