0
2
<script lang="ts">
import { profilePath } from '$lib/utils'
import { goto } from '$app/navigation'
import { applyWrites, atUri, createWrite, nextTid, uploadPhotoBlobs } from '$lib/utils/records'
import { useQueryClient } from '@tanstack/svelte-query'
import { parseInstagramExport, type ParsedPost } from '$lib/utils/instagram-import'
import { resizeImage } from '$lib/utils/image-resize'
import DetailHeader from '$lib/components/molecules/DetailHeader.svelte'
import Button from '$lib/components/atoms/Button.svelte'
import ContentWarningPicker from '$lib/components/atoms/ContentWarningPicker.svelte'
import { LoaderCircle, Check, ImageIcon, X } from 'lucide-svelte'
import SelectCheck from '$lib/components/atoms/SelectCheck.svelte'
import { viewer } from '$lib/stores'
function galleryTitle(date: Date): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
let step = $state<'select' | 'review' | 'importing' | 'done'>('select')
let posts = $state<ParsedPost[]>([])
let parsing = $state(false)
let parseProgress = $state('')
let error = $state<string | null>(null)
let importProgress = $state({ current: 0, total: 0 })
let importedCount = $state(0)
let fileInput: HTMLInputElement = $state()!
const selectedCount = $derived(posts.filter((p) => p.selected).length)
const queryClient = useQueryClient()
function openFilePicker() {
fileInput?.click()
}
async function handleFileSelected(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (!file.name.endsWith('.zip')) {
error = 'Please select a .zip file from your Instagram export.'
return
}
parsing = true
error = null
try {
posts = await parseInstagramExport(file, (msg) => {
parseProgress = msg
})
if (posts.length === 0) {
error = 'No posts with photos found in this export.'
return
}
step = 'review'
} catch (err: any) {
error = err.message || 'Failed to parse Instagram export.'
console.error(err)
} finally {
parsing = false
}
}
function togglePost(index: number) {
posts[index].selected = !posts[index].selected
}
function selectAll() {
for (const post of posts) post.selected = true
}
function deselectAll() {
for (const post of posts) post.selected = false
}
function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
async function importSelected() {
const selected = posts.filter((p) => p.selected)
if (selected.length === 0) return
const did = $viewer?.did
if (!did) {
error = 'You must be signed in to import.'
return
}
step = 'importing'
importProgress = { current: 0, total: selected.length }
importedCount = 0
error = null
for (const post of selected) {
importProgress.current++
try {
const createdAt = post.createdAt.toISOString()
// 1. Upload blobs (must be individual calls)
const blobs = await uploadPhotoBlobs(post.photos.map((p) => p.dataUrl))
// 2. Every record for this post in one atomic applyWrites. Minting the
// rkeys up front means the gallery items can reference a gallery
// created in the same call, so this no longer needs a second round
// trip — and a replayed request can't double-import a post.
const photoRkeys = post.photos.map(() => nextTid())
const galleryRkey = nextTid()
const galleryUri = atUri(did, 'social.grain.gallery', galleryRkey)
const writes = [
...post.photos.map((photo, i) =>
createWrite('social.grain.photo', photoRkeys[i], {
photo: blobs[i],
aspectRatio: { width: photo.width, height: photo.height },
alt: '',
createdAt,
}),
),
createWrite('social.grain.gallery', galleryRkey, {
title: galleryTitle(post.createdAt),
...(post.description.trim() ? { description: post.description.trim() } : {}),
...(post.labels.length > 0
? {
labels: {
$type: 'com.atproto.label.defs#selfLabels',
values: post.labels.map((val: string) => ({ val })),
},
}
: {}),
createdAt,
}),
...photoRkeys.map((photoRkey, i) =>
createWrite('social.grain.gallery.item', nextTid(), {
gallery: galleryUri,
item: atUri(did, 'social.grain.photo', photoRkey),
position: i,
createdAt,
}),
),
]
await applyWrites(writes)
importedCount++
} catch (err: unknown) {
console.error(`Failed to import post ${post.index}:`, err)
error = `Failed on post ${importProgress.current} of ${importProgress.total}. ${importedCount} imported successfully.`
step = 'review'
return
}
}
queryClient.invalidateQueries({ queryKey: ['getFeed'] })
step = 'done'
}
</script>
<DetailHeader label="Import from Instagram" onback={step === 'review' ? () => { step = 'select'; posts = [] } : undefined} />
<div class="import-page">
{#if error}
<p class="error">{error}</p>
{/if}
<!-- Step: Select File -->
{#if step === 'select'}
<div class="step-select">
<input
type="file"
accept=".zip"
bind:this={fileInput}
onchange={handleFileSelected}
style="display:none"
/>
<div class="instructions">
<h3>How to export from Instagram</h3>
<ol>
<li>Open Instagram and go to <strong>Settings and activity</strong></li>
<li>Tap <strong>Your activity</strong>, then <strong>Download your information</strong></li>
<li>Tap <strong>Request a download</strong></li>
<li>Select your Instagram account</li>
<li>Choose <strong>Select types of information</strong> and pick <strong>Posts</strong></li>
<li>Choose <strong>Format: JSON</strong> and <strong>Media quality: High</strong></li>
<li>Tap <strong>Create files</strong></li>
<li>Wait for Instagram to email you the download link (can take up to 48 hours)</li>
<li>Download the .zip file and select it below</li>
</ol>
</div>
<button class="select-btn" onclick={openFilePicker} disabled={parsing}>
{#if parsing}
<LoaderCircle size={24} class="spin" />
<span>{parseProgress}</span>
{:else}
<span>Select Instagram Export</span>
<span class="hint">Choose the .zip file (JSON format)</span>
{/if}
</button>
</div>
<!-- Step: Review Posts -->
{:else if step === 'review'}
<div class="review-header">
<div class="review-summary">
<span class="count">{selectedCount} of {posts.length} posts selected</span>
<div class="select-actions">
<button class="text-btn" onclick={selectAll}>Select all</button>
<button class="text-btn" onclick={deselectAll}>Deselect all</button>
</div>
</div>
<Button disabled={selectedCount === 0} onclick={importSelected}>
Import {selectedCount} {selectedCount === 1 ? 'post' : 'posts'}
</Button>
</div>
<div class="post-list">
{#each posts as post, i}
<div class="post-card" class:deselected={!post.selected}>
<button class="post-check" type="button" onclick={() => togglePost(i)}>
<SelectCheck checked={post.selected} />
</button>
<div class="post-content">
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div onclick={() => togglePost(i)} class="post-clickable">
<div class="photo-strip">
{#each post.photos as photo}
<img class="thumb" src={photo.dataUrl} alt="" />
{/each}
</div>
<div class="post-meta">
<span class="post-date">{formatDate(post.createdAt)}</span>
<span class="post-photo-count">
<ImageIcon size={12} />
{post.photos.length}
</span>
</div>
{#if post.description}
<p class="post-description">{post.description}</p>
{/if}
</div>
<div class="post-labels" onclick={(e) => e.stopPropagation()}>
<ContentWarningPicker bind:selected={posts[i].labels} />
</div>
</div>
</div>
{/each}
</div>
<!-- Step: Importing -->
{:else if step === 'importing'}
<div class="step-importing">
<LoaderCircle size={32} class="spin" />
<p class="importing-text">Importing {importProgress.current} of {importProgress.total}...</p>
<p class="importing-sub">Please don't close this page.</p>
</div>
<!-- Step: Done -->
{:else if step === 'done'}
<div class="step-done">
<div class="done-icon"><Check size={32} /></div>
<p class="done-text">Imported {importedCount} {importedCount === 1 ? 'gallery' : 'galleries'}</p>
<Button onclick={() => goto(profilePath($viewer))}>View Profile</Button>
</div>
{/if}
</div>
<style>
.import-page {
max-width: 600px;
margin: 0 auto;
min-height: 100vh;
}
.error {
color: var(--danger);
padding: 12px 16px;
margin: 0;
text-align: center;
font-size: 14px;
}
/* Select step */
.step-select {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 16px;
gap: 24px;
}
.select-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
background: var(--bg-hover);
border: 2px dashed var(--border);
border-radius: 16px;
padding: 40px 48px;
cursor: pointer;
color: var(--text-primary);
font-size: 16px;
font-weight: 600;
font-family: inherit;
transition: border-color 0.15s;
}
.instructions {
width: 100%;
max-width: 400px;
}
.instructions h3 {
font-size: 15px;
font-weight: 600;
margin: 0 0 12px;
}
.instructions ol {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.select-btn:hover { border-color: var(--grain); }
.select-btn:disabled { cursor: not-allowed; opacity: 0.6; }
.hint {
font-size: 13px;
font-weight: 400;
color: var(--text-muted);
}
/* Review step */
.review-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 46px;
background: var(--bg-blur);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
z-index: 10;
}
.review-summary {
display: flex;
flex-direction: column;
gap: 4px;
}
.count {
font-size: 14px;
font-weight: 600;
}
.select-actions {
display: flex;
gap: 12px;
}
.text-btn {
background: none;
border: none;
padding: 0;
font-size: 13px;
font-family: inherit;
color: var(--grain);
cursor: pointer;
}
.text-btn:hover { opacity: 0.8; }
/* Post list */
.post-list {
display: flex;
flex-direction: column;
}
.post-card {
display: flex;
gap: 12px;
padding: 14px 16px;
color: var(--text-primary);
border-bottom: 1px solid var(--border);
transition: opacity 0.15s;
}
.post-card.deselected {
opacity: 0.4;
}
.post-clickable {
cursor: pointer;
display: flex;
flex-direction: column;
gap: 6px;
}
.post-clickable:hover {
opacity: 0.8;
}
.post-check {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
align-self: flex-start;
}
.post-labels {
margin-top: 4px;
}
.post-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.photo-strip {
display: flex;
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
}
.photo-strip::-webkit-scrollbar { display: none; }
.thumb {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.post-meta {
display: flex;
align-items: center;
gap: 10px;
}
.post-date {
font-size: 12px;
color: var(--text-muted);
}
.post-photo-count {
display: flex;
align-items: center;
gap: 3px;
font-size: 12px;
color: var(--text-muted);
}
.post-description {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: pre-wrap;
}
/* Importing step */
.step-importing {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 12px;
}
.importing-text {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.importing-sub {
font-size: 13px;
color: var(--text-muted);
margin: 0;
}
/* Done step */
.step-done {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 16px;
}
.done-icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--grain);
color: var(--on-grain);
display: flex;
align-items: center;
justify-content: center;
}
.done-text {
font-size: 18px;
font-weight: 600;
margin: 0;
}
:global(.spin) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>