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

Configure Feed

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

feat: add content warning labels to import, beta badge on settings

Each post in the import review now has a ContentWarningPicker for
self-labeling. Settings link shows a Beta badge. Review header uses
glassmorphism background matching the detail header.

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

+66 -34
+2
app/lib/utils/instagram-import.ts
··· 21 21 createdAt: Date 22 22 photos: ParsedPhoto[] 23 23 selected: boolean 24 + labels: string[] 24 25 } 25 26 26 27 export interface ParsedPhoto { ··· 125 126 createdAt: new Date(post.creation_timestamp * 1000), 126 127 photos, 127 128 selected: true, 129 + labels: [], 128 130 }) 129 131 } 130 132
+13 -1
app/routes/settings/+page.svelte
··· 42 42 43 43 <div class="settings-group"> 44 44 <a href="/settings/import" class="settings-row"> 45 - <span class="settings-label">Import from Instagram</span> 45 + <span class="settings-label">Import from Instagram <span class="beta-badge">Beta</span></span> 46 46 <ChevronRight size={16} class="chevron" /> 47 47 </a> 48 48 </div> ··· 121 121 } 122 122 .settings-row :global(.chevron) { 123 123 color: var(--text-muted); 124 + } 125 + .beta-badge { 126 + font-size: 10px; 127 + font-weight: 600; 128 + text-transform: uppercase; 129 + letter-spacing: 0.5px; 130 + background: var(--bg-hover); 131 + color: var(--text-muted); 132 + padding: 2px 6px; 133 + border-radius: 4px; 134 + vertical-align: middle; 135 + margin-left: 6px; 124 136 } 125 137 .sign-out .settings-label { 126 138 color: #f87171;
+51 -33
app/routes/settings/import/+page.svelte
··· 6 6 import { resizeImage } from '$lib/utils/image-resize' 7 7 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 8 8 import Button from '$lib/components/atoms/Button.svelte' 9 + import ContentWarningPicker from '$lib/components/atoms/ContentWarningPicker.svelte' 9 10 import { LoaderCircle, Check, ImageIcon, X } from 'lucide-svelte' 10 11 import { viewer } from '$lib/stores' 11 12 ··· 118 119 record: { 119 120 title: galleryTitle(post.createdAt), 120 121 ...(post.description.trim() ? { description: post.description.trim() } : {}), 122 + ...(post.labels.length > 0 123 + ? { 124 + labels: { 125 + $type: 'com.atproto.label.defs#selfLabels', 126 + values: post.labels.map((val: string) => ({ val })), 127 + }, 128 + } 129 + : {}), 121 130 createdAt, 122 131 }, 123 132 }) ··· 209 218 210 219 <div class="post-list"> 211 220 {#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"> 221 + <div class="post-card" class:deselected={!post.selected}> 222 + <button class="post-check" type="button" onclick={() => togglePost(i)}> 219 223 {#if post.selected} 220 224 <div class="check-on"><Check size={14} /></div> 221 225 {:else} 222 226 <div class="check-off"></div> 223 227 {/if} 224 - </div> 228 + </button> 225 229 <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 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 231 + <div onclick={() => togglePost(i)} class="post-clickable"> 232 + <div class="photo-strip"> 233 + {#each post.photos as photo} 234 + <img class="thumb" src={photo.dataUrl} alt="" /> 235 + {/each} 236 + </div> 237 + <div class="post-meta"> 238 + <span class="post-date">{formatDate(post.createdAt)}</span> 239 + <span class="post-photo-count"> 240 + <ImageIcon size={12} /> 241 + {post.photos.length} 242 + </span> 243 + </div> 244 + {#if post.description} 245 + <p class="post-description">{post.description}</p> 246 + {/if} 230 247 </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> 248 + <div class="post-labels" onclick={(e) => e.stopPropagation()}> 249 + <ContentWarningPicker bind:selected={posts[i].labels} /> 237 250 </div> 238 - {#if post.description} 239 - <p class="post-description">{post.description}</p> 240 - {/if} 241 251 </div> 242 - </button> 252 + </div> 243 253 {/each} 244 254 </div> 245 255 ··· 336 346 border-bottom: 1px solid var(--border); 337 347 position: sticky; 338 348 top: 46px; 339 - background: var(--bg-root); 349 + background: rgba(8, 11, 18, 0.85); 350 + backdrop-filter: blur(16px); 351 + -webkit-backdrop-filter: blur(16px); 340 352 z-index: 10; 341 353 } 342 354 .review-summary { ··· 372 384 display: flex; 373 385 gap: 12px; 374 386 padding: 14px 16px; 375 - border: none; 376 - background: none; 377 - text-align: left; 378 - cursor: pointer; 379 - font-family: inherit; 380 387 color: var(--text-primary); 381 388 border-bottom: 1px solid var(--border); 382 389 transition: opacity 0.15s; 383 - width: 100%; 384 - } 385 - .post-card:hover { 386 - background: var(--bg-hover); 387 390 } 388 391 .post-card.deselected { 389 392 opacity: 0.4; 390 393 } 394 + .post-clickable { 395 + cursor: pointer; 396 + display: flex; 397 + flex-direction: column; 398 + gap: 6px; 399 + } 400 + .post-clickable:hover { 401 + opacity: 0.8; 402 + } 391 403 .post-check { 392 404 flex-shrink: 0; 393 - padding-top: 4px; 405 + background: none; 406 + border: none; 407 + cursor: pointer; 408 + align-self: flex-start; 409 + } 410 + .post-labels { 411 + margin-top: 4px; 394 412 } 395 413 .check-on { 396 414 width: 22px;