your personal website on atproto - mirror blento.app
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

fixes

Florian f06dc60e 4aa344b8

+157 -127
-100
src/lib/cards/core/SecretImageCard/CreateSecretImageCardModal.svelte
··· 1 - <script lang="ts"> 2 - import { Button, Input, Subheading } from '@foxui/core'; 3 - import Modal from '$lib/components/modal/Modal.svelte'; 4 - import type { CreationModalComponentProps } from '../../types'; 5 - import { compressImage } from '$lib/atproto/image-helper'; 6 - import { createPixelatedPreview, encryptBlob } from './crypto'; 7 - 8 - let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 9 - 10 - let fileInput = $state<HTMLInputElement | undefined>(undefined); 11 - let imagePreview = $state<string | undefined>(undefined); 12 - let selectedFile = $state<Blob | undefined>(undefined); 13 - let password = $state(''); 14 - let errorMessage = $state(''); 15 - let processing = $state(false); 16 - 17 - async function handleFileChange(event: Event) { 18 - const target = event.target as HTMLInputElement; 19 - const file = target.files?.[0]; 20 - if (!file) return; 21 - 22 - const { blob } = await compressImage(file); 23 - selectedFile = blob; 24 - imagePreview = URL.createObjectURL(blob); 25 - } 26 - 27 - async function handleCreate() { 28 - errorMessage = ''; 29 - if (!selectedFile) { 30 - errorMessage = 'Please select an image'; 31 - return; 32 - } 33 - if (!password.trim()) { 34 - errorMessage = 'Please enter a password'; 35 - return; 36 - } 37 - 38 - processing = true; 39 - try { 40 - const preview = await createPixelatedPreview(selectedFile); 41 - const encrypted = await encryptBlob(selectedFile, password.trim()); 42 - 43 - item.cardData = { 44 - encryptedImage: { blob: encrypted }, 45 - preview 46 - }; 47 - 48 - oncreate(); 49 - } catch { 50 - errorMessage = 'Failed to encrypt image'; 51 - processing = false; 52 - } 53 - } 54 - </script> 55 - 56 - <Modal open={true} closeButton={false}> 57 - <form 58 - onsubmit={(e) => { 59 - e.preventDefault(); 60 - handleCreate(); 61 - }} 62 - class="flex flex-col gap-4" 63 - > 64 - <Subheading>Secret Image</Subheading> 65 - 66 - <div> 67 - <button 68 - type="button" 69 - onclick={() => fileInput?.click()} 70 - class="border-base-300 dark:border-base-600 hover:bg-base-100 dark:hover:bg-base-700 flex h-32 w-full cursor-pointer items-center justify-center overflow-hidden rounded-xl border-2 border-dashed transition-colors" 71 - > 72 - {#if imagePreview} 73 - <img src={imagePreview} alt="Preview" class="h-full w-full object-cover" /> 74 - {:else} 75 - <span class="text-base-500 text-sm">Click to select image</span> 76 - {/if} 77 - </button> 78 - <input 79 - bind:this={fileInput} 80 - type="file" 81 - accept="image/*" 82 - class="hidden" 83 - onchange={handleFileChange} 84 - /> 85 - </div> 86 - 87 - <Input type="password" bind:value={password} placeholder="Enter password" /> 88 - 89 - {#if errorMessage} 90 - <p class="text-sm text-red-600">{errorMessage}</p> 91 - {/if} 92 - 93 - <div class="flex justify-end gap-2"> 94 - <Button onclick={oncancel} variant="ghost">Cancel</Button> 95 - <Button type="submit" disabled={processing}> 96 - {processing ? 'Encrypting...' : 'Create'} 97 - </Button> 98 - </div> 99 - </form> 100 - </Modal>
+106
src/lib/cards/core/SecretImageCard/EditingSecretImageCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '../../types'; 3 + import { compressImage } from '$lib/atproto/image-helper'; 4 + 5 + let { item = $bindable() }: ContentComponentProps = $props(); 6 + 7 + let fileInput = $state<HTMLInputElement | undefined>(undefined); 8 + let dragOver = $state(false); 9 + 10 + const hasImage = $derived(item.cardData.rawImage?.objectUrl || item.cardData.preview); 11 + 12 + const imageUrl = $derived(item.cardData.rawImage?.objectUrl || item.cardData.preview); 13 + 14 + async function handleFile(file: File) { 15 + const { blob } = await compressImage(file); 16 + const objectUrl = URL.createObjectURL(blob); 17 + item.cardData.rawImage = { blob, objectUrl }; 18 + } 19 + 20 + function handleDragOver(event: DragEvent) { 21 + event.preventDefault(); 22 + event.stopPropagation(); 23 + dragOver = true; 24 + } 25 + 26 + function handleDragLeave(event: DragEvent) { 27 + event.preventDefault(); 28 + event.stopPropagation(); 29 + dragOver = false; 30 + } 31 + 32 + async function handleDrop(event: DragEvent) { 33 + event.preventDefault(); 34 + event.stopPropagation(); 35 + dragOver = false; 36 + const file = event.dataTransfer?.files?.[0]; 37 + if (file?.type.startsWith('image/')) { 38 + await handleFile(file); 39 + } 40 + } 41 + 42 + async function handleFileInput(event: Event) { 43 + const target = event.target as HTMLInputElement; 44 + const file = target.files?.[0]; 45 + if (file) await handleFile(file); 46 + } 47 + </script> 48 + 49 + <!-- svelte-ignore a11y_no_static_element_interactions --> 50 + <div 51 + class="absolute inset-0 flex flex-col" 52 + ondragover={handleDragOver} 53 + ondragleave={handleDragLeave} 54 + ondrop={handleDrop} 55 + > 56 + <input 57 + bind:this={fileInput} 58 + type="file" 59 + accept="image/*" 60 + class="hidden" 61 + onchange={handleFileInput} 62 + /> 63 + 64 + <!-- Image area --> 65 + <button 66 + type="button" 67 + class="relative flex-1 cursor-pointer overflow-hidden" 68 + onclick={() => fileInput?.click()} 69 + > 70 + {#if hasImage} 71 + <img class="absolute inset-0 h-full w-full object-cover" src={imageUrl} alt="" /> 72 + <div 73 + class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors hover:bg-black/30" 74 + > 75 + <span class="text-sm font-medium text-white opacity-0 transition-opacity hover:opacity-100"> 76 + Replace 77 + </span> 78 + </div> 79 + {:else} 80 + <div 81 + class="flex h-full items-center justify-center {dragOver 82 + ? 'bg-accent-100 dark:bg-accent-900/30' 83 + : ''}" 84 + > 85 + <div class="text-base-400 flex flex-col items-center gap-1"> 86 + <svg 87 + xmlns="http://www.w3.org/2000/svg" 88 + fill="none" 89 + viewBox="0 0 24 24" 90 + stroke-width="1.5" 91 + stroke="currentColor" 92 + class="size-8" 93 + > 94 + <path 95 + stroke-linecap="round" 96 + stroke-linejoin="round" 97 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3 21h18a1.5 1.5 0 0 0 1.5-1.5V6A1.5 1.5 0 0 0 21 4.5H3A1.5 1.5 0 0 0 1.5 6v12A1.5 1.5 0 0 0 3 21Z" 98 + /> 99 + </svg> 100 + <span class="text-xs">Drop or click</span> 101 + </div> 102 + </div> 103 + {/if} 104 + </button> 105 + 106 + </div>
+2 -8
src/lib/cards/core/SecretImageCard/SecretImageCard.svelte
··· 55 55 56 56 {#if decryptedUrl} 57 57 <img 58 - class="absolute inset-0 h-full w-full object-cover animate-in fade-in duration-500" 58 + class="animate-in fade-in absolute inset-0 h-full w-full object-cover duration-500" 59 59 src={decryptedUrl} 60 60 alt="" 61 61 /> ··· 71 71 fill="none" 72 72 viewBox="0 0 24 24" 73 73 > 74 - <circle 75 - class="opacity-25" 76 - cx="12" 77 - cy="12" 78 - r="10" 79 - stroke="currentColor" 80 - stroke-width="4" 74 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" 81 75 ></circle> 82 76 <path 83 77 class="opacity-75"
+8
src/lib/cards/core/SecretImageCard/SecretImageCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { Input } from '@foxui/core'; 4 + 5 + let { item }: { item: Item; onclose: () => void } = $props(); 6 + </script> 7 + 8 + <Input type="password" bind:value={item.cardData.password} placeholder="Enter password" />
+3 -6
src/lib/cards/core/SecretImageCard/crypto.ts
··· 2 2 * AES-GCM encryption/decryption using Web Crypto API with password-derived keys. 3 3 */ 4 4 5 - async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> { 5 + async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>): Promise<CryptoKey> { 6 6 const encoder = new TextEncoder(); 7 7 const keyMaterial = await crypto.subtle.importKey( 8 8 'raw', ··· 16 16 { 17 17 name: 'PBKDF2', 18 18 salt, 19 - iterations: 100000, 19 + iterations: 10000, 20 20 hash: 'SHA-256' 21 21 }, 22 22 keyMaterial, ··· 66 66 /** 67 67 * Create a tiny pixelated preview of an image (16x16 pixels stored as a base64 data URL). 68 68 */ 69 - export function createPixelatedPreview( 70 - file: Blob, 71 - size: number = 16 72 - ): Promise<string> { 69 + export function createPixelatedPreview(file: Blob, size: number = 16): Promise<string> { 73 70 return new Promise((resolve, reject) => { 74 71 const img = new Image(); 75 72 const reader = new FileReader();
+38 -13
src/lib/cards/core/SecretImageCard/index.ts
··· 1 1 import { uploadBlob } from '$lib/atproto/methods'; 2 2 import type { CardDefinition } from '../../types'; 3 - import CreateSecretImageCardModal from './CreateSecretImageCardModal.svelte'; 3 + import { createPixelatedPreview, encryptBlob } from './crypto'; 4 + import EditingSecretImageCard from './EditingSecretImageCard.svelte'; 4 5 import SecretImageCard from './SecretImageCard.svelte'; 6 + import SecretImageCardSettings from './SecretImageCardSettings.svelte'; 5 7 6 8 export const SecretImageCardDefinition = { 7 9 type: 'secretImage', 8 10 contentComponent: SecretImageCard, 9 - 10 - creationModalComponent: CreateSecretImageCardModal, 11 + editingContentComponent: EditingSecretImageCard, 12 + settingsComponent: SecretImageCardSettings, 11 13 12 14 createNew: (card) => { 13 15 card.cardType = 'secretImage'; 14 16 card.cardData = { 15 17 encryptedImage: '', 16 - preview: '' 18 + preview: '', 19 + password: '', 20 + rawImage: null 17 21 }; 18 22 }, 19 23 20 24 upload: async (item) => { 21 - const img = item.cardData.encryptedImage; 22 - if (!img) return item; 25 + // If there's a new raw image + password, encrypt and upload 26 + if (item.cardData.rawImage?.blob && item.cardData.password) { 27 + const rawBlob = item.cardData.rawImage.blob as Blob; 28 + const password = item.cardData.password as string; 29 + 30 + // Generate pixelated preview 31 + item.cardData.preview = await createPixelatedPreview(rawBlob); 23 32 24 - // Already uploaded 25 - if (typeof img === 'object' && img.$type === 'blob') return item; 33 + // Encrypt the image 34 + const encrypted = await encryptBlob(rawBlob, password); 35 + 36 + // Upload encrypted blob 37 + item.cardData.encryptedImage = await uploadBlob({ blob: encrypted }); 26 38 27 - // Local blob from creation modal 28 - if (img?.blob) { 29 - if (img.objectUrl) { 30 - URL.revokeObjectURL(img.objectUrl); 39 + // Clean up local state 40 + if (item.cardData.rawImage.objectUrl) { 41 + URL.revokeObjectURL(item.cardData.rawImage.objectUrl); 31 42 } 32 - item.cardData.encryptedImage = await uploadBlob({ blob: img.blob }); 43 + delete item.cardData.rawImage; 44 + delete item.cardData.password; 45 + 46 + return item; 47 + } 48 + 49 + // Already uploaded encrypted blob - nothing to do 50 + if ( 51 + typeof item.cardData.encryptedImage === 'object' && 52 + item.cardData.encryptedImage?.$type === 'blob' 53 + ) { 54 + // Clean up editing-only fields before save 55 + delete item.cardData.rawImage; 56 + delete item.cardData.password; 57 + return item; 33 58 } 34 59 35 60 return item;