mirror of
https://github.com/cupcakearmy/cryptgeon.git
synced 2026-06-10 11:16:48 +00:00
f5823fb533
Remove trailing whitespace throughout the Create.svelte component and add missing alt attribute to pasted image previews for better accessibility.
384 lines
9.0 KiB
Svelte
384 lines
9.0 KiB
Svelte
<script lang="ts">
|
|
import { AES, Hex } from 'occulto'
|
|
import { t } from 'svelte-intl-precompile'
|
|
import { blur } from 'svelte/transition'
|
|
|
|
import { status } from '$lib/stores/status'
|
|
import { notify } from '$lib/toast'
|
|
import AdvancedParameters from '$lib/ui/AdvancedParameters.svelte'
|
|
import Button from '$lib/ui/Button.svelte'
|
|
import FileUpload from '$lib/ui/FileUpload.svelte'
|
|
import Loader from '$lib/ui/Loader.svelte'
|
|
import MaxSize from '$lib/ui/MaxSize.svelte'
|
|
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
|
|
import Switch from '$lib/ui/Switch.svelte'
|
|
import TextArea from '$lib/ui/TextArea.svelte'
|
|
import { Adapters, API, PayloadToLargeError, type FileDTO, type Note } from 'cryptgeon/shared'
|
|
|
|
let note: Note = $state({
|
|
contents: '',
|
|
meta: { type: 'text' },
|
|
views: 1,
|
|
expiration: 60,
|
|
})
|
|
let files: FileDTO[] = $state([])
|
|
let result: NoteResult | null = $state(null)
|
|
let advanced = $state(false)
|
|
let isFile = $state(false)
|
|
let timeExpiration = $state(false)
|
|
let customPassword: string | null = $state(null)
|
|
let description = $state('')
|
|
let loading: string | null = $state(null)
|
|
|
|
// Image paste functionality
|
|
let pastedImages: { preview: string; file: File }[] = $state([])
|
|
let isPasting = $state(false)
|
|
let pasteIndicator = $state<HTMLDivElement | null>(null)
|
|
|
|
$effect(() => {
|
|
if (!advanced) {
|
|
note.views = 1
|
|
timeExpiration = false
|
|
}
|
|
})
|
|
|
|
$effect(() => {
|
|
description = $t('home.explanation', {
|
|
values: {
|
|
type: $t(timeExpiration ? 'common.minutes' : 'common.views', {
|
|
values: { n: (timeExpiration ? note.expiration : note.views) ?? '?' },
|
|
}),
|
|
},
|
|
})
|
|
})
|
|
|
|
$effect(() => {
|
|
note.meta.type = isFile ? 'file' : 'text'
|
|
})
|
|
|
|
$effect(() => {
|
|
if (!isFile) {
|
|
note.contents = ''
|
|
}
|
|
})
|
|
|
|
async function handlePaste(e: ClipboardEvent) {
|
|
// Use the standard DataTransfer API to access pasted content
|
|
const items = e.clipboardData?.items
|
|
if (!items || items.length === 0) return
|
|
|
|
isPasting = true
|
|
|
|
const imagePromises: Promise<File | null>[] = []
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i]
|
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
const file = item.getAsFile()
|
|
if (file) {
|
|
const extension = file.type.split('/')[1] || 'png'
|
|
const renamed = new File(
|
|
[file],
|
|
`pasted-image-${Date.now()}-${Math.round(Math.random() * 1000)}.${extension}`,
|
|
{ type: file.type },
|
|
)
|
|
imagePromises.push(Promise.resolve(renamed))
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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 {}
|
|
|
|
async function submit(e: SubmitEvent) {
|
|
e.preventDefault()
|
|
try {
|
|
loading = $t('common.encrypting')
|
|
|
|
const derived = customPassword && (await AES.derive(customPassword))
|
|
const key = derived ? derived[0] : await AES.generateKey()
|
|
|
|
const data: Note = {
|
|
contents: '',
|
|
meta: note.meta,
|
|
}
|
|
if (derived) data.meta.derivation = derived[1]
|
|
if (isFile) {
|
|
if (files.length === 0) throw new EmptyContentError()
|
|
data.contents = await Adapters.Files.encrypt(files, key)
|
|
} else {
|
|
if (note.contents === '') throw new EmptyContentError()
|
|
data.contents = await Adapters.Text.encrypt(note.contents, key)
|
|
}
|
|
if (timeExpiration) data.expiration = parseInt(note.expiration as any)
|
|
else data.views = parseInt(note.views as any)
|
|
|
|
loading = $t('common.uploading')
|
|
const response = await API.create(data)
|
|
result = {
|
|
id: response.id,
|
|
password: customPassword ? undefined : Hex.encode(key),
|
|
}
|
|
notify.success($t('home.messages.note_created'))
|
|
} catch (e) {
|
|
if (e instanceof PayloadToLargeError) {
|
|
notify.error($t('home.errors.note_to_big'))
|
|
} else if (e instanceof EmptyContentError) {
|
|
notify.error($t('home.errors.empty_content'))
|
|
} else {
|
|
console.error(e)
|
|
notify.error($t('home.errors.note_error'))
|
|
}
|
|
} finally {
|
|
loading = null
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if result}
|
|
<Result {result} />
|
|
{:else}
|
|
<p>
|
|
{@html $status?.theme_text || $t('home.intro')}
|
|
</p>
|
|
<form onsubmit={submit} onpaste={handlePaste}>
|
|
<fieldset disabled={loading !== null}>
|
|
<div class="paste-indicator" bind:this={pasteIndicator}>
|
|
{#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}
|
|
|
|
{#if pastedImages.length > 0}
|
|
<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 class="bottom">
|
|
{#if $status?.allow_files}
|
|
<Switch
|
|
data-testid="switch-file"
|
|
class="file"
|
|
label={$t('common.file')}
|
|
bind:value={isFile}
|
|
/>
|
|
{/if}
|
|
{#if $status?.allow_advanced}
|
|
<Switch
|
|
data-testid="switch-advanced"
|
|
label={$t('common.advanced')}
|
|
bind:value={advanced}
|
|
/>
|
|
{/if}
|
|
<div class="grow"></div>
|
|
<div class="tr">
|
|
<small>{$t('common.max')}: <MaxSize /> </small>
|
|
<br />
|
|
<Button type="submit">{$t('common.create')}</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<p>
|
|
<br />
|
|
{#if loading}
|
|
{loading} <Loader />
|
|
{:else}
|
|
{description}
|
|
{/if}
|
|
</p>
|
|
|
|
{#if advanced}
|
|
<div transition:blur|global={{ duration: 250 }}>
|
|
<hr />
|
|
<AdvancedParameters bind:note bind:timeExpiration bind:customPassword />
|
|
</div>
|
|
{/if}
|
|
</fieldset>
|
|
</form>
|
|
{/if}
|
|
|
|
<style>
|
|
.bottom {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.bottom :global(.file) {
|
|
margin-right: 0.5rem;
|
|
}
|
|
|
|
.grow {
|
|
flex: 1;
|
|
}
|
|
|
|
/* Paste indicator styles */
|
|
.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); }
|
|
}
|
|
|
|
.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>
|