grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

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

feat: add Instagram import from settings page

Parse Instagram JSON export zip in-browser, preview posts with
thumbnails, and batch-import as galleries. Titles use the post date,
captions map to descriptions (truncated to 1000 chars), and original
timestamps are preserved as createdAt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+664 -5
+2 -2
app/lib/components/molecules/StoryStrip.svelte
··· 83 83 {/if} 84 84 85 85 {#if menuOpen} 86 - <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 87 - <div class="own-menu" style="left: {menuX}px; top: {menuY}px;" onclick={(e) => e.stopPropagation()}> 86 + <!-- svelte-ignore a11y_click_events_have_key_events --> 87 + <div class="own-menu" role="menu" tabindex="-1" style="left: {menuX}px; top: {menuY}px;" onclick={(e) => e.stopPropagation()}> 88 88 <button class="menu-item" onclick={handleMenuCreate}>Create story</button> 89 89 <button class="menu-item" onclick={handleMenuView}>View your story</button> 90 90 </div>
+132
app/lib/utils/instagram-import.ts
··· 1 + import { unzipSync } from 'fflate' 2 + import { resizeImage } from './image-resize' 3 + 4 + export interface InstagramMedia { 5 + uri: string 6 + creation_timestamp: number 7 + media_metadata?: { 8 + camera_metadata?: { has_camera_metadata: boolean } 9 + } 10 + } 11 + 12 + export interface InstagramPost { 13 + media: InstagramMedia[] 14 + title: string 15 + creation_timestamp: number 16 + } 17 + 18 + export interface ParsedPost { 19 + index: number 20 + description: string 21 + createdAt: Date 22 + photos: ParsedPhoto[] 23 + selected: boolean 24 + } 25 + 26 + export interface ParsedPhoto { 27 + dataUrl: string 28 + width: number 29 + height: number 30 + originalUri: string 31 + } 32 + 33 + /** 34 + * Instagram exports store UTF-8 text as Latin-1 byte values. 35 + * Each UTF-8 byte is stored as a separate Latin-1 character. 36 + * Decode by treating each char code as a byte and re-decoding as UTF-8. 37 + */ 38 + function decodeInstagramText(text: string): string { 39 + try { 40 + const bytes = new Uint8Array(text.length) 41 + for (let i = 0; i < text.length; i++) { 42 + bytes[i] = text.charCodeAt(i) 43 + } 44 + return new TextDecoder('utf-8').decode(bytes) 45 + } catch { 46 + return text 47 + } 48 + } 49 + 50 + function fileToDataUrl(data: Uint8Array, mimeType: string): Promise<string> { 51 + return new Promise((resolve, reject) => { 52 + const blob = new Blob([data as BlobPart], { type: mimeType }) 53 + const reader = new FileReader() 54 + reader.onload = () => resolve(reader.result as string) 55 + reader.onerror = reject 56 + reader.readAsDataURL(blob) 57 + }) 58 + } 59 + 60 + function getMimeType(uri: string): string { 61 + const ext = uri.split('.').pop()?.toLowerCase() 62 + if (ext === 'png') return 'image/png' 63 + if (ext === 'webp') return 'image/webp' 64 + return 'image/jpeg' 65 + } 66 + 67 + export async function parseInstagramExport( 68 + file: File, 69 + onProgress?: (message: string) => void, 70 + ): Promise<ParsedPost[]> { 71 + onProgress?.('Reading zip file...') 72 + const buffer = await file.arrayBuffer() 73 + const files = unzipSync(new Uint8Array(buffer)) 74 + 75 + // Find posts JSON 76 + onProgress?.('Finding posts...') 77 + const postsKey = Object.keys(files).find( 78 + (k) => k.includes('media/posts_1.json') || k.includes('media/posts.json'), 79 + ) 80 + if (!postsKey) { 81 + throw new Error('Could not find posts JSON in the Instagram export. Make sure you selected the JSON format export.') 82 + } 83 + 84 + const postsJson = new TextDecoder().decode(files[postsKey]) 85 + const posts: InstagramPost[] = JSON.parse(postsJson) 86 + 87 + const parsed: ParsedPost[] = [] 88 + 89 + for (let i = 0; i < posts.length; i++) { 90 + const post = posts[i] 91 + onProgress?.(`Processing post ${i + 1} of ${posts.length}...`) 92 + 93 + const photos: ParsedPhoto[] = [] 94 + for (const media of post.media) { 95 + // Only process images (skip videos) 96 + const ext = media.uri.split('.').pop()?.toLowerCase() 97 + if (ext === 'mp4' || ext === 'mov' || ext === 'avi') continue 98 + 99 + const mediaData = files[media.uri] 100 + if (!mediaData) continue 101 + 102 + const mimeType = getMimeType(media.uri) 103 + const rawDataUrl = await fileToDataUrl(mediaData, mimeType) 104 + 105 + // Resize through existing pipeline 106 + const resized = await resizeImage(rawDataUrl, { 107 + width: 2000, 108 + height: 2000, 109 + maxSize: 900_000, 110 + }) 111 + 112 + photos.push({ 113 + dataUrl: resized.dataUrl, 114 + width: resized.width, 115 + height: resized.height, 116 + originalUri: media.uri, 117 + }) 118 + } 119 + 120 + if (photos.length === 0) continue 121 + 122 + parsed.push({ 123 + index: i, 124 + description: decodeInstagramText(post.title || '').slice(0, 1000), 125 + createdAt: new Date(post.creation_timestamp * 1000), 126 + photos, 127 + selected: true, 128 + }) 129 + } 130 + 131 + return parsed 132 + }
+7
app/routes/settings/+page.svelte
··· 41 41 </div> 42 42 43 43 <div class="settings-group"> 44 + <a href="/settings/import" class="settings-row"> 45 + <span class="settings-label">Import from Instagram</span> 46 + <ChevronRight size={16} class="chevron" /> 47 + </a> 48 + </div> 49 + 50 + <div class="settings-group"> 44 51 <a href="/support/privacy" class="settings-row"> 45 52 <span class="settings-label">Privacy Policy</span> 46 53 <ChevronRight size={16} class="chevron" />
+512
app/routes/settings/import/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from '$app/navigation' 3 + import { callXrpc } from '$hatk/client' 4 + import { useQueryClient } from '@tanstack/svelte-query' 5 + import { parseInstagramExport, type ParsedPost } from '$lib/utils/instagram-import' 6 + import { resizeImage } from '$lib/utils/image-resize' 7 + import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 8 + import Button from '$lib/components/atoms/Button.svelte' 9 + import { LoaderCircle, Check, ImageIcon, X } from 'lucide-svelte' 10 + import { viewer } from '$lib/stores' 11 + 12 + function galleryTitle(date: Date): string { 13 + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) 14 + } 15 + 16 + let step = $state<'select' | 'review' | 'importing' | 'done'>('select') 17 + let posts = $state<ParsedPost[]>([]) 18 + let parsing = $state(false) 19 + let parseProgress = $state('') 20 + let error = $state<string | null>(null) 21 + let importProgress = $state({ current: 0, total: 0 }) 22 + let importedCount = $state(0) 23 + let fileInput: HTMLInputElement = $state()! 24 + 25 + const selectedCount = $derived(posts.filter((p) => p.selected).length) 26 + const queryClient = useQueryClient() 27 + 28 + function openFilePicker() { 29 + fileInput?.click() 30 + } 31 + 32 + async function handleFileSelected(e: Event) { 33 + const input = e.target as HTMLInputElement 34 + const file = input.files?.[0] 35 + input.value = '' 36 + if (!file) return 37 + if (!file.name.endsWith('.zip')) { 38 + error = 'Please select a .zip file from your Instagram export.' 39 + return 40 + } 41 + 42 + parsing = true 43 + error = null 44 + try { 45 + posts = await parseInstagramExport(file, (msg) => { 46 + parseProgress = msg 47 + }) 48 + if (posts.length === 0) { 49 + error = 'No posts with photos found in this export.' 50 + return 51 + } 52 + step = 'review' 53 + } catch (err: any) { 54 + error = err.message || 'Failed to parse Instagram export.' 55 + console.error(err) 56 + } finally { 57 + parsing = false 58 + } 59 + } 60 + 61 + function togglePost(index: number) { 62 + posts[index].selected = !posts[index].selected 63 + } 64 + 65 + function selectAll() { 66 + for (const post of posts) post.selected = true 67 + } 68 + 69 + function deselectAll() { 70 + for (const post of posts) post.selected = false 71 + } 72 + 73 + function formatDate(date: Date): string { 74 + return date.toLocaleDateString('en-US', { 75 + year: 'numeric', 76 + month: 'short', 77 + day: 'numeric', 78 + }) 79 + } 80 + 81 + async function importSelected() { 82 + const selected = posts.filter((p) => p.selected) 83 + if (selected.length === 0) return 84 + 85 + step = 'importing' 86 + importProgress = { current: 0, total: selected.length } 87 + importedCount = 0 88 + error = null 89 + 90 + for (const post of selected) { 91 + importProgress.current++ 92 + try { 93 + const createdAt = post.createdAt.toISOString() 94 + const photoUris: string[] = [] 95 + 96 + // Upload photos 97 + for (const photo of post.photos) { 98 + const base64 = photo.dataUrl.split(',')[1] 99 + const binary = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) 100 + const blob = new Blob([binary], { type: 'image/jpeg' }) 101 + 102 + const uploadResult = await callXrpc('dev.hatk.uploadBlob', blob as any) 103 + 104 + const photoResult = await callXrpc('dev.hatk.createRecord', { 105 + collection: 'social.grain.photo', 106 + record: { 107 + photo: (uploadResult as any).blob, 108 + aspectRatio: { width: photo.width, height: photo.height }, 109 + createdAt, 110 + }, 111 + }) 112 + photoUris.push((photoResult as any).uri as string) 113 + } 114 + 115 + // Create gallery 116 + const galleryResult = await callXrpc('dev.hatk.createRecord', { 117 + collection: 'social.grain.gallery', 118 + record: { 119 + title: galleryTitle(post.createdAt), 120 + ...(post.description.trim() ? { description: post.description.trim() } : {}), 121 + createdAt, 122 + }, 123 + }) 124 + const galleryUri = (galleryResult as any).uri as string 125 + 126 + // Create gallery items 127 + for (let i = 0; i < photoUris.length; i++) { 128 + await callXrpc('dev.hatk.createRecord', { 129 + collection: 'social.grain.gallery.item', 130 + record: { 131 + gallery: galleryUri, 132 + item: photoUris[i], 133 + position: i, 134 + createdAt, 135 + }, 136 + }) 137 + } 138 + 139 + importedCount++ 140 + } catch (err: any) { 141 + console.error(`Failed to import post ${post.index}:`, err) 142 + error = `Failed on post ${importProgress.current} of ${importProgress.total}. ${importedCount} imported successfully.` 143 + step = 'review' 144 + return 145 + } 146 + } 147 + 148 + queryClient.invalidateQueries({ queryKey: ['getFeed'] }) 149 + step = 'done' 150 + } 151 + </script> 152 + 153 + <DetailHeader label="Import from Instagram" onback={step === 'review' ? () => { step = 'select'; posts = [] } : undefined} /> 154 + 155 + <div class="import-page"> 156 + {#if error} 157 + <p class="error">{error}</p> 158 + {/if} 159 + 160 + <!-- Step: Select File --> 161 + {#if step === 'select'} 162 + <div class="step-select"> 163 + <input 164 + type="file" 165 + accept=".zip" 166 + bind:this={fileInput} 167 + onchange={handleFileSelected} 168 + style="display:none" 169 + /> 170 + <div class="instructions"> 171 + <h3>How to export from Instagram</h3> 172 + <ol> 173 + <li>Open Instagram and go to <strong>Settings and activity</strong></li> 174 + <li>Tap <strong>Your activity</strong>, then <strong>Download your information</strong></li> 175 + <li>Tap <strong>Request a download</strong></li> 176 + <li>Select your Instagram account</li> 177 + <li>Choose <strong>Select types of information</strong> and pick <strong>Posts</strong></li> 178 + <li>Choose <strong>Format: JSON</strong> and <strong>Media quality: High</strong></li> 179 + <li>Tap <strong>Create files</strong></li> 180 + <li>Wait for Instagram to email you the download link (can take up to 48 hours)</li> 181 + <li>Download the .zip file and select it below</li> 182 + </ol> 183 + </div> 184 + <button class="select-btn" onclick={openFilePicker} disabled={parsing}> 185 + {#if parsing} 186 + <LoaderCircle size={24} class="spin" /> 187 + <span>{parseProgress}</span> 188 + {:else} 189 + <span>Select Instagram Export</span> 190 + <span class="hint">Choose the .zip file (JSON format)</span> 191 + {/if} 192 + </button> 193 + </div> 194 + 195 + <!-- Step: Review Posts --> 196 + {:else if step === 'review'} 197 + <div class="review-header"> 198 + <div class="review-summary"> 199 + <span class="count">{selectedCount} of {posts.length} posts selected</span> 200 + <div class="select-actions"> 201 + <button class="text-btn" onclick={selectAll}>Select all</button> 202 + <button class="text-btn" onclick={deselectAll}>Deselect all</button> 203 + </div> 204 + </div> 205 + <Button disabled={selectedCount === 0} onclick={importSelected}> 206 + Import {selectedCount} {selectedCount === 1 ? 'post' : 'posts'} 207 + </Button> 208 + </div> 209 + 210 + <div class="post-list"> 211 + {#each posts as post, i} 212 + <button 213 + class="post-card" 214 + class:deselected={!post.selected} 215 + type="button" 216 + onclick={() => togglePost(i)} 217 + > 218 + <div class="post-check"> 219 + {#if post.selected} 220 + <div class="check-on"><Check size={14} /></div> 221 + {:else} 222 + <div class="check-off"></div> 223 + {/if} 224 + </div> 225 + <div class="post-content"> 226 + <div class="photo-strip"> 227 + {#each post.photos as photo} 228 + <img class="thumb" src={photo.dataUrl} alt="" /> 229 + {/each} 230 + </div> 231 + <div class="post-meta"> 232 + <span class="post-date">{formatDate(post.createdAt)}</span> 233 + <span class="post-photo-count"> 234 + <ImageIcon size={12} /> 235 + {post.photos.length} 236 + </span> 237 + </div> 238 + {#if post.description} 239 + <p class="post-description">{post.description}</p> 240 + {/if} 241 + </div> 242 + </button> 243 + {/each} 244 + </div> 245 + 246 + <!-- Step: Importing --> 247 + {:else if step === 'importing'} 248 + <div class="step-importing"> 249 + <LoaderCircle size={32} class="spin" /> 250 + <p class="importing-text">Importing {importProgress.current} of {importProgress.total}...</p> 251 + <p class="importing-sub">Please don't close this page.</p> 252 + </div> 253 + 254 + <!-- Step: Done --> 255 + {:else if step === 'done'} 256 + <div class="step-done"> 257 + <div class="done-icon"><Check size={32} /></div> 258 + <p class="done-text">Imported {importedCount} {importedCount === 1 ? 'gallery' : 'galleries'}</p> 259 + <Button onclick={() => goto(`/profile/${$viewer?.did}`)}>View Profile</Button> 260 + </div> 261 + {/if} 262 + </div> 263 + 264 + <style> 265 + .import-page { 266 + max-width: 600px; 267 + margin: 0 auto; 268 + min-height: 100vh; 269 + } 270 + 271 + .error { 272 + color: #f87171; 273 + padding: 12px 16px; 274 + margin: 0; 275 + text-align: center; 276 + font-size: 14px; 277 + } 278 + 279 + /* Select step */ 280 + .step-select { 281 + display: flex; 282 + flex-direction: column; 283 + align-items: center; 284 + padding: 32px 16px; 285 + gap: 24px; 286 + } 287 + .select-btn { 288 + display: flex; 289 + flex-direction: column; 290 + align-items: center; 291 + gap: 8px; 292 + background: var(--bg-hover); 293 + border: 2px dashed var(--border); 294 + border-radius: 16px; 295 + padding: 40px 48px; 296 + cursor: pointer; 297 + color: var(--text-primary); 298 + font-size: 16px; 299 + font-weight: 600; 300 + font-family: inherit; 301 + transition: border-color 0.15s; 302 + } 303 + .instructions { 304 + width: 100%; 305 + max-width: 400px; 306 + } 307 + .instructions h3 { 308 + font-size: 15px; 309 + font-weight: 600; 310 + margin: 0 0 12px; 311 + } 312 + .instructions ol { 313 + margin: 0; 314 + padding-left: 20px; 315 + display: flex; 316 + flex-direction: column; 317 + gap: 8px; 318 + font-size: 13px; 319 + color: var(--text-secondary); 320 + line-height: 1.5; 321 + } 322 + .select-btn:hover { border-color: var(--grain); } 323 + .select-btn:disabled { cursor: not-allowed; opacity: 0.6; } 324 + .hint { 325 + font-size: 13px; 326 + font-weight: 400; 327 + color: var(--text-muted); 328 + } 329 + 330 + /* Review step */ 331 + .review-header { 332 + display: flex; 333 + align-items: center; 334 + justify-content: space-between; 335 + padding: 12px 16px; 336 + border-bottom: 1px solid var(--border); 337 + position: sticky; 338 + top: 46px; 339 + background: var(--bg-root); 340 + z-index: 10; 341 + } 342 + .review-summary { 343 + display: flex; 344 + flex-direction: column; 345 + gap: 4px; 346 + } 347 + .count { 348 + font-size: 14px; 349 + font-weight: 600; 350 + } 351 + .select-actions { 352 + display: flex; 353 + gap: 12px; 354 + } 355 + .text-btn { 356 + background: none; 357 + border: none; 358 + padding: 0; 359 + font-size: 13px; 360 + font-family: inherit; 361 + color: var(--grain); 362 + cursor: pointer; 363 + } 364 + .text-btn:hover { opacity: 0.8; } 365 + 366 + /* Post list */ 367 + .post-list { 368 + display: flex; 369 + flex-direction: column; 370 + } 371 + .post-card { 372 + display: flex; 373 + gap: 12px; 374 + padding: 14px 16px; 375 + border: none; 376 + background: none; 377 + text-align: left; 378 + cursor: pointer; 379 + font-family: inherit; 380 + color: var(--text-primary); 381 + border-bottom: 1px solid var(--border); 382 + transition: opacity 0.15s; 383 + width: 100%; 384 + } 385 + .post-card:hover { 386 + background: var(--bg-hover); 387 + } 388 + .post-card.deselected { 389 + opacity: 0.4; 390 + } 391 + .post-check { 392 + flex-shrink: 0; 393 + padding-top: 4px; 394 + } 395 + .check-on { 396 + width: 22px; 397 + height: 22px; 398 + border-radius: 50%; 399 + background: var(--grain); 400 + color: #fff; 401 + display: flex; 402 + align-items: center; 403 + justify-content: center; 404 + } 405 + .check-off { 406 + width: 22px; 407 + height: 22px; 408 + border-radius: 50%; 409 + border: 2px solid var(--border); 410 + } 411 + .post-content { 412 + flex: 1; 413 + min-width: 0; 414 + display: flex; 415 + flex-direction: column; 416 + gap: 6px; 417 + } 418 + .photo-strip { 419 + display: flex; 420 + gap: 4px; 421 + overflow-x: auto; 422 + scrollbar-width: none; 423 + } 424 + .photo-strip::-webkit-scrollbar { display: none; } 425 + .thumb { 426 + width: 56px; 427 + height: 56px; 428 + object-fit: cover; 429 + border-radius: 4px; 430 + flex-shrink: 0; 431 + } 432 + .post-meta { 433 + display: flex; 434 + align-items: center; 435 + gap: 10px; 436 + } 437 + .post-date { 438 + font-size: 12px; 439 + color: var(--text-muted); 440 + } 441 + .post-photo-count { 442 + display: flex; 443 + align-items: center; 444 + gap: 3px; 445 + font-size: 12px; 446 + color: var(--text-muted); 447 + } 448 + .post-description { 449 + margin: 0; 450 + font-size: 13px; 451 + color: var(--text-secondary); 452 + display: -webkit-box; 453 + -webkit-line-clamp: 2; 454 + line-clamp: 2; 455 + -webkit-box-orient: vertical; 456 + overflow: hidden; 457 + white-space: pre-wrap; 458 + } 459 + 460 + /* Importing step */ 461 + .step-importing { 462 + display: flex; 463 + flex-direction: column; 464 + align-items: center; 465 + justify-content: center; 466 + min-height: 300px; 467 + gap: 12px; 468 + } 469 + .importing-text { 470 + font-size: 16px; 471 + font-weight: 600; 472 + margin: 0; 473 + } 474 + .importing-sub { 475 + font-size: 13px; 476 + color: var(--text-muted); 477 + margin: 0; 478 + } 479 + 480 + /* Done step */ 481 + .step-done { 482 + display: flex; 483 + flex-direction: column; 484 + align-items: center; 485 + justify-content: center; 486 + min-height: 300px; 487 + gap: 16px; 488 + } 489 + .done-icon { 490 + width: 56px; 491 + height: 56px; 492 + border-radius: 50%; 493 + background: var(--grain); 494 + color: #fff; 495 + display: flex; 496 + align-items: center; 497 + justify-content: center; 498 + } 499 + .done-text { 500 + font-size: 18px; 501 + font-weight: 600; 502 + margin: 0; 503 + } 504 + 505 + :global(.spin) { 506 + animation: spin 1s linear infinite; 507 + } 508 + @keyframes spin { 509 + from { transform: rotate(0deg); } 510 + to { transform: rotate(360deg); } 511 + } 512 + </style>
+10 -3
package-lock.json
··· 11 11 "@sveltejs/kit": "^2.55.0", 12 12 "@tanstack/svelte-query": "^6.1.0", 13 13 "exifr": "^7.1.3", 14 + "fflate": "^0.8.2", 14 15 "h3-js": "^4.4.0", 15 16 "lucide-svelte": "^0.576.0" 16 17 }, ··· 1914 1915 "node": ">= 8.0.0" 1915 1916 } 1916 1917 }, 1918 + "node_modules/@shuding/opentype.js/node_modules/fflate": { 1919 + "version": "0.7.4", 1920 + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", 1921 + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", 1922 + "license": "MIT" 1923 + }, 1917 1924 "node_modules/@standard-schema/spec": { 1918 1925 "version": "1.1.0", 1919 1926 "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", ··· 2899 2906 } 2900 2907 }, 2901 2908 "node_modules/fflate": { 2902 - "version": "0.7.4", 2903 - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", 2904 - "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", 2909 + "version": "0.8.2", 2910 + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", 2911 + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", 2905 2912 "license": "MIT" 2906 2913 }, 2907 2914 "node_modules/file-uri-to-path": {
+1
package.json
··· 17 17 "@sveltejs/kit": "^2.55.0", 18 18 "@tanstack/svelte-query": "^6.1.0", 19 19 "exifr": "^7.1.3", 20 + "fflate": "^0.8.2", 20 21 "h3-js": "^4.4.0", 21 22 "lucide-svelte": "^0.576.0" 22 23 },