This commit is contained in:
2026-06-07 13:53:38 +02:00
parent f55925de86
commit 9c4fbc30f9
15 changed files with 255 additions and 165 deletions
+3
View File
@@ -6,3 +6,6 @@ watchexec = "latest"
[settings] [settings]
idiomatic_version_file_enable_tools = ["node"] idiomatic_version_file_enable_tools = ["node"]
[env]
SIZE_LIMIT = "100mb"
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "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.", "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" "custom_password": "Vlastní heslo"
} },
"pasting": "Vkládání...",
"pasted_files": "Vložené soubory",
"remove": "Odstranit"
}, },
"show": { "show": {
"errors": { "errors": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "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.", "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" "custom_password": "Benutzerdefiniertes Passwort"
} },
"pasting": "Einfügen...",
"pasted_files": "Eingefügte Dateien",
"remove": "Entfernen"
}, },
"show": { "show": {
"errors": { "errors": {
+2 -2
View File
@@ -37,8 +37,8 @@
"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.", "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" "custom_password": "custom password"
}, },
"pasting": "Pasting image...", "pasting": "Pasting...",
"pasted_images": "Pasted Images", "pasted_files": "Pasted Files",
"remove": "Remove" "remove": "Remove"
}, },
"show": { "show": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "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.", "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" "custom_password": "contraseña personalizada"
} },
"pasting": "Pegando...",
"pasted_files": "Archivos pegados",
"remove": "Eliminar"
}, },
"show": { "show": {
"errors": { "errors": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "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.", "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é" "custom_password": "mot de passe personnalisé"
} },
"pasting": "Collage...",
"pasted_files": "Fichiers collés",
"remove": "Supprimer"
}, },
"show": { "show": {
"errors": { "errors": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "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.", "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" "custom_password": "password personalizzata"
} },
"pasting": "Incollando...",
"pasted_files": "File incollati",
"remove": "Rimuovi"
}, },
"show": { "show": {
"errors": { "errors": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "デフォルトでは、安全に生成されたパスワードが各ノートに使用されます。しかし、リンクに含まれない独自のパスワードを選択することもできます。", "explanation": "デフォルトでは、安全に生成されたパスワードが各ノートに使用されます。しかし、リンクに含まれない独自のパスワードを選択することもできます。",
"custom_password": "カスタムパスワード" "custom_password": "カスタムパスワード"
} },
"pasting": "貼り付け中...",
"pasted_files": "貼り付けたファイル",
"remove": "削除"
}, },
"show": { "show": {
"errors": { "errors": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "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.", "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" "custom_password": "własne hasło"
} },
"pasting": "Wklejanie...",
"pasted_files": "Wklejone pliki",
"remove": "Usuń"
}, },
"show": { "show": {
"errors": { "errors": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "По умолчанию для каждой заметки используется безопасно сгенерированный пароль. Однако вы также можете выбрать свой собственный пароль, который не включен в ссылку.", "explanation": "По умолчанию для каждой заметки используется безопасно сгенерированный пароль. Однако вы также можете выбрать свой собственный пароль, который не включен в ссылку.",
"custom_password": "пользовательский пароль" "custom_password": "пользовательский пароль"
} },
"pasting": "Вставка...",
"pasted_files": "Вставленные файлы",
"remove": "Удалить"
}, },
"show": { "show": {
"errors": { "errors": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "預設情況下,每個筆記都會使用安全生成的密碼。您也可以選擇自己的密碼,該密碼不會包含在連結中。", "explanation": "預設情況下,每個筆記都會使用安全生成的密碼。您也可以選擇自己的密碼,該密碼不會包含在連結中。",
"custom_password": "自定義密碼" "custom_password": "自定義密碼"
} },
"pasting": "正在粘貼...",
"pasted_files": "粘貼的檔案",
"remove": "移除"
}, },
"show": { "show": {
"errors": { "errors": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。", "explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。",
"custom_password": "自定义密码" "custom_password": "自定义密码"
} },
"pasting": "正在粘贴...",
"pasted_files": "粘贴的文件",
"remove": "移除"
}, },
"show": { "show": {
"errors": { "errors": {
@@ -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>
+51 -147
View File
@@ -10,6 +10,7 @@
import FileUpload from '$lib/ui/FileUpload.svelte' import FileUpload from '$lib/ui/FileUpload.svelte'
import Loader from '$lib/ui/Loader.svelte' import Loader from '$lib/ui/Loader.svelte'
import MaxSize from '$lib/ui/MaxSize.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 Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
import Switch from '$lib/ui/Switch.svelte' import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte' import TextArea from '$lib/ui/TextArea.svelte'
@@ -29,11 +30,7 @@
let customPassword: string | null = $state(null) let customPassword: string | null = $state(null)
let description = $state('') let description = $state('')
let loading: string | null = $state(null) let loading: string | null = $state(null)
// Image paste functionality
let pastedImages: { preview: string; file: File }[] = $state([])
let isPasting = $state(false) let isPasting = $state(false)
let pasteIndicator = $state<HTMLDivElement | null>(null)
$effect(() => { $effect(() => {
if (!advanced) { if (!advanced) {
@@ -63,82 +60,60 @@
}) })
async function handlePaste(e: ClipboardEvent) { async function handlePaste(e: ClipboardEvent) {
// Use the standard DataTransfer API to access pasted content e.preventDefault()
const items = e.clipboardData?.items const data = e.clipboardData
if (!items || items.length === 0) return 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 isPasting = true
const imagePromises: Promise<File | null>[] = [] const dtos: FileDTO[] = await Promise.all(
for (let i = 0; i < items.length; i++) { pasted.map(async (file) => {
const item = items[i] const ext = file.name.includes('.') ? '' : `.${file.type.split('/')[1] || 'bin'}`
if (item.kind === 'file' && item.type.startsWith('image/')) { const name =
const file = item.getAsFile() file.name || `pasted-file-${Date.now()}-${Math.round(Math.random() * 1000)}${ext}`
if (file) { const renamed = new File([file], name, { type: file.type })
const extension = file.type.split('/')[1] || 'png' return {
const renamed = new File( name: renamed.name,
[file], size: renamed.size,
`pasted-image-${Date.now()}-${Math.round(Math.random() * 1000)}.${extension}`, type: renamed.type,
{ type: file.type }, contents: new Uint8Array(await renamed.arrayBuffer()),
}
})
) )
imagePromises.push(Promise.resolve(renamed))
} if (dtos.length > 0) {
} if (!isFile) isFile = true
files = [...files, ...dtos]
} }
try {
const results = await Promise.all(imagePromises)
const imageFiles = results.filter((file): file is File => file !== null)
if (imageFiles.length > 0) {
// Switch to file mode if not already
if (!isFile) {
isFile = true
}
// Process each image for preview and add to files
for (const imageFile of imageFiles) {
// Create preview URL
const previewURL = URL.createObjectURL(imageFile)
// Add to pasted images for preview
pastedImages = [...pastedImages, { preview: previewURL, file: imageFile }]
// Convert to FileDTO and add to files array
const arrayBuffer = await imageFile.arrayBuffer()
const fileDTO: FileDTO = {
name: imageFile.name,
size: imageFile.size,
type: imageFile.type,
contents: new Uint8Array(arrayBuffer)
}
// Add to files if not already present
if (!files.some(f => f.name === imageFile.name && f.size === imageFile.size)) {
files = [...files, fileDTO]
}
}
}
} catch (error) {
console.error('Error processing pasted image:', error)
} finally {
isPasting = false isPasting = false
} }
}
function removePastedImage(index: number) {
const removed = pastedImages.splice(index, 1)[0]
// Revoke the object URL to free memory
URL.revokeObjectURL(removed.preview)
// Also remove from files array
files = files.filter(file => !(file.name === removed.file.name && file.size === removed.file.size))
// If no more pasted images and no other files, switch back to text mode
if (pastedImages.length === 0 && files.length === 0) {
isFile = false
}
}
class EmptyContentError extends Error {} class EmptyContentError extends Error {}
@@ -195,7 +170,7 @@
</p> </p>
<form onsubmit={submit} onpaste={handlePaste}> <form onsubmit={submit} onpaste={handlePaste}>
<fieldset disabled={loading !== null}> <fieldset disabled={loading !== null}>
<div class="paste-indicator" bind:this={pasteIndicator}> <div class="paste-indicator">
{#if isPasting} {#if isPasting}
<div class="pasting-overlay"> <div class="pasting-overlay">
<div class="pasting-spinner"></div> <div class="pasting-spinner"></div>
@@ -213,26 +188,7 @@
/> />
{/if} {/if}
{#if pastedImages.length > 0} <PastedFilesPreview bind:files />
<div class="pasted-images-preview">
<h4>{$t('home.pasted_images')}</h4>
<div class="images-grid">
{#each pastedImages as image, index}
<div class="image-preview">
<img src={image.preview} class="preview-img" alt="Pasted image" />
<div class="image-actions">
<Button
onclick={() => removePastedImage(index)}
>
{$t('home.remove')}
</Button>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div> </div>
<div class="bottom"> <div class="bottom">
@@ -293,7 +249,6 @@
flex: 1; flex: 1;
} }
/* Paste indicator styles */
.paste-indicator { .paste-indicator {
position: relative; position: relative;
min-height: 200px; min-height: 200px;
@@ -325,59 +280,8 @@
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
} transform: rotate(360deg);
.pasted-images-preview {
margin-top: 1rem;
padding: 1rem;
border: 1px dashed #ccc;
border-radius: 4px;
background-color: #fafafa;
}
.pasted-images-preview h4 {
margin-top: 0;
margin-bottom: 0.5rem;
color: #333;
}
.images-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.image-preview {
position: relative;
text-align: center;
}
.preview-img {
max-width: 150px;
max-height: 150px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.image-actions {
margin-top: 0.5rem;
}
/* Responsive design */
@media (max-width: 640px) {
.pasted-images-preview {
margin-top: 0.5rem;
padding: 0.5rem;
}
.preview-img {
max-width: 120px;
max-height: 120px;
}
.images-grid {
gap: 0.5rem;
} }
} }
</style> </style>
+3 -5
View File
@@ -1,10 +1,10 @@
import { sveltekit } from '@sveltejs/kit/vite' import { sveltekit } from '@sveltejs/kit/vite'
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin' import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
import { defineConfig } from 'vite'
const port = 3000 const port = 3000
/** @type {import('vite').UserConfig} */ export default defineConfig({
const config = {
clearScreen: false, clearScreen: false,
server: { server: {
port, port,
@@ -14,6 +14,4 @@ const config = {
}, },
preview: { port }, preview: { port },
plugins: [sveltekit(), precompileIntl('locales')], plugins: [sveltekit(), precompileIntl('locales')],
} })
export default config