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

Configure Feed

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

Merge pull request #266 from flo-bit/feat/pixelated-image-card

commit

authored by

Florian and committed by
GitHub
212d9be2 44191601

+364 -1
+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/SecretImageCard.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/state'; 3 + import { getDidContext } from '$lib/website/context'; 4 + import { getBlobURL } from '$lib/atproto'; 5 + import { decryptBlob } from './crypto'; 6 + import type { ContentComponentProps } from '../../types'; 7 + 8 + let { item = $bindable() }: ContentComponentProps = $props(); 9 + 10 + const did = getDidContext(); 11 + 12 + let decryptedUrl = $state<string | null>(null); 13 + let decrypting = $state(false); 14 + 15 + $effect(() => { 16 + const secret = page.url.searchParams.get('secret'); 17 + const blob = item.cardData.encryptedImage; 18 + if (!secret || !blob || blob.$type !== 'blob') return; 19 + 20 + decrypting = true; 21 + decryptImage(secret, blob); 22 + 23 + return () => { 24 + if (decryptedUrl) { 25 + URL.revokeObjectURL(decryptedUrl); 26 + decryptedUrl = null; 27 + } 28 + }; 29 + }); 30 + 31 + async function decryptImage(password: string, blob: any) { 32 + try { 33 + const url = await getBlobURL({ did, blob }); 34 + const response = await fetch(url); 35 + if (!response.ok) throw new Error('Failed to fetch blob'); 36 + const encryptedBlob = await response.blob(); 37 + const decrypted = await decryptBlob(encryptedBlob, password); 38 + decryptedUrl = URL.createObjectURL(decrypted); 39 + } catch { 40 + // Wrong password or fetch error - stay pixelated 41 + } finally { 42 + decrypting = false; 43 + } 44 + } 45 + </script> 46 + 47 + {#if item.cardData.preview} 48 + <img 49 + class="absolute inset-0 h-full w-full object-cover" 50 + style="image-rendering: pixelated;" 51 + src={item.cardData.preview} 52 + alt="" 53 + /> 54 + {/if} 55 + 56 + {#if decryptedUrl} 57 + <img 58 + class="absolute inset-0 h-full w-full object-cover animate-in fade-in duration-500" 59 + src={decryptedUrl} 60 + alt="" 61 + /> 62 + {/if} 63 + 64 + {#if !decryptedUrl} 65 + <div class="absolute inset-0 flex items-center justify-center"> 66 + <div class="bg-base-900/40 rounded-full p-3 backdrop-blur-sm"> 67 + {#if decrypting} 68 + <svg 69 + class="size-6 animate-spin text-white" 70 + xmlns="http://www.w3.org/2000/svg" 71 + fill="none" 72 + viewBox="0 0 24 24" 73 + > 74 + <circle 75 + class="opacity-25" 76 + cx="12" 77 + cy="12" 78 + r="10" 79 + stroke="currentColor" 80 + stroke-width="4" 81 + ></circle> 82 + <path 83 + class="opacity-75" 84 + fill="currentColor" 85 + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" 86 + ></path> 87 + </svg> 88 + {:else} 89 + <svg 90 + xmlns="http://www.w3.org/2000/svg" 91 + fill="none" 92 + viewBox="0 0 24 24" 93 + stroke-width="2" 94 + stroke="currentColor" 95 + class="size-6 text-white" 96 + > 97 + <path 98 + stroke-linecap="round" 99 + stroke-linejoin="round" 100 + d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" 101 + /> 102 + </svg> 103 + {/if} 104 + </div> 105 + </div> 106 + {/if}
+98
src/lib/cards/core/SecretImageCard/crypto.ts
··· 1 + /** 2 + * AES-GCM encryption/decryption using Web Crypto API with password-derived keys. 3 + */ 4 + 5 + async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> { 6 + const encoder = new TextEncoder(); 7 + const keyMaterial = await crypto.subtle.importKey( 8 + 'raw', 9 + encoder.encode(password), 10 + 'PBKDF2', 11 + false, 12 + ['deriveKey'] 13 + ); 14 + 15 + return crypto.subtle.deriveKey( 16 + { 17 + name: 'PBKDF2', 18 + salt, 19 + iterations: 100000, 20 + hash: 'SHA-256' 21 + }, 22 + keyMaterial, 23 + { name: 'AES-GCM', length: 256 }, 24 + false, 25 + ['encrypt', 'decrypt'] 26 + ); 27 + } 28 + 29 + /** 30 + * Encrypt a Blob with a password. Returns a Blob containing salt + iv + ciphertext. 31 + */ 32 + export async function encryptBlob(blob: Blob, password: string): Promise<Blob> { 33 + const salt = crypto.getRandomValues(new Uint8Array(16)); 34 + const iv = crypto.getRandomValues(new Uint8Array(12)); 35 + const key = await deriveKey(password, salt); 36 + 37 + const plaintext = await blob.arrayBuffer(); 38 + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext); 39 + 40 + // Pack: salt (16) + iv (12) + ciphertext 41 + const result = new Uint8Array(16 + 12 + ciphertext.byteLength); 42 + result.set(salt, 0); 43 + result.set(iv, 16); 44 + result.set(new Uint8Array(ciphertext), 28); 45 + 46 + return new Blob([result], { type: 'application/octet-stream' }); 47 + } 48 + 49 + /** 50 + * Decrypt a Blob that was encrypted with encryptBlob. Returns the original Blob. 51 + * Throws on wrong password. 52 + */ 53 + export async function decryptBlob(encryptedBlob: Blob, password: string): Promise<Blob> { 54 + const data = new Uint8Array(await encryptedBlob.arrayBuffer()); 55 + 56 + const salt = data.slice(0, 16); 57 + const iv = data.slice(16, 28); 58 + const ciphertext = data.slice(28); 59 + 60 + const key = await deriveKey(password, salt); 61 + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); 62 + 63 + return new Blob([plaintext]); 64 + } 65 + 66 + /** 67 + * Create a tiny pixelated preview of an image (16x16 pixels stored as a base64 data URL). 68 + */ 69 + export function createPixelatedPreview( 70 + file: Blob, 71 + size: number = 16 72 + ): Promise<string> { 73 + return new Promise((resolve, reject) => { 74 + const img = new Image(); 75 + const reader = new FileReader(); 76 + 77 + reader.onload = (e) => { 78 + if (!e.target?.result) return reject(new Error('Failed to read file')); 79 + img.src = e.target.result as string; 80 + }; 81 + reader.onerror = reject; 82 + reader.readAsDataURL(file); 83 + 84 + img.onload = () => { 85 + const canvas = document.createElement('canvas'); 86 + canvas.width = size; 87 + canvas.height = size; 88 + const ctx = canvas.getContext('2d'); 89 + if (!ctx) return reject(new Error('Failed to get canvas context')); 90 + 91 + ctx.imageSmoothingEnabled = true; 92 + ctx.drawImage(img, 0, 0, size, size); 93 + 94 + resolve(canvas.toDataURL('image/webp', 0.5)); 95 + }; 96 + img.onerror = reject; 97 + }); 98 + }
+57
src/lib/cards/core/SecretImageCard/index.ts
··· 1 + import { uploadBlob } from '$lib/atproto/methods'; 2 + import type { CardDefinition } from '../../types'; 3 + import CreateSecretImageCardModal from './CreateSecretImageCardModal.svelte'; 4 + import SecretImageCard from './SecretImageCard.svelte'; 5 + 6 + export const SecretImageCardDefinition = { 7 + type: 'secretImage', 8 + contentComponent: SecretImageCard, 9 + 10 + creationModalComponent: CreateSecretImageCardModal, 11 + 12 + createNew: (card) => { 13 + card.cardType = 'secretImage'; 14 + card.cardData = { 15 + encryptedImage: '', 16 + preview: '' 17 + }; 18 + }, 19 + 20 + upload: async (item) => { 21 + const img = item.cardData.encryptedImage; 22 + if (!img) return item; 23 + 24 + // Already uploaded 25 + if (typeof img === 'object' && img.$type === 'blob') return item; 26 + 27 + // Local blob from creation modal 28 + if (img?.blob) { 29 + if (img.objectUrl) { 30 + URL.revokeObjectURL(img.objectUrl); 31 + } 32 + item.cardData.encryptedImage = await uploadBlob({ blob: img.blob }); 33 + } 34 + 35 + return item; 36 + }, 37 + 38 + name: 'Secret Image', 39 + 40 + keywords: ['secret', 'encrypted', 'password', 'hidden', 'private', 'locked'], 41 + groups: ['Core'], 42 + 43 + icon: `<svg 44 + xmlns="http://www.w3.org/2000/svg" 45 + fill="none" 46 + viewBox="0 0 24 24" 47 + stroke-width="2" 48 + stroke="currentColor" 49 + class="size-4" 50 + > 51 + <path 52 + stroke-linecap="round" 53 + stroke-linejoin="round" 54 + d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" 55 + /> 56 + </svg>` 57 + } as CardDefinition & { type: 'secretImage' };
+3 -1
src/lib/cards/index.ts
··· 56 56 import { KichRecipeCardDefinition } from './social/KichRecipeCard'; 57 57 import { KichRecipeCollectionCardDefinition } from './social/KichRecipeCollectionCard'; 58 58 import { KichCookingLogCardDefinition } from './social/KichCookingLogCard'; 59 + import { SecretImageCardDefinition } from './core/SecretImageCard'; 59 60 // import { Model3DCardDefinition } from './visual/Model3DCard'; 60 61 61 62 export const AllCardDefinitions = [ ··· 117 118 GermDMCardDefinition, 118 119 KichRecipeCardDefinition, 119 120 KichRecipeCollectionCardDefinition, 120 - KichCookingLogCardDefinition 121 + KichCookingLogCardDefinition, 122 + SecretImageCardDefinition 121 123 ] as const; 122 124 123 125 export const CardDefinitionsByType = AllCardDefinitions.reduce(