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: align web settings with native app structure

- Add Account page with handle, DID, and manage data link
- Add Upload Defaults page (location, camera data toggles)
- Move privacy settings out of Edit Profile into Upload Defaults
- Add legal links and sign out to main settings page
- Update privacy policy: add location data section with H3/reverse
geocoding details, correct EXIF metadata description

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

+251 -49
+66 -1
app/routes/settings/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 - import { UserPen, Shield, Bell, ChevronRight } from 'lucide-svelte' 3 + import { UserPen, Shield, Bell, ChevronRight, ExternalLink, User, Upload } from 'lucide-svelte' 4 + import { viewer } from '$lib/stores' 5 + import { logout } from '$lib/auth' 6 + import { resetPreferences } from '$lib/preferences' 7 + 8 + const did = $derived($viewer?.did ?? '') 9 + 10 + async function doLogout() { 11 + await logout() 12 + resetPreferences() 13 + window.location.href = '/' 14 + } 4 15 </script> 5 16 6 17 <DetailHeader label="Settings" /> 7 18 8 19 <div class="settings-page"> 9 20 <div class="settings-group"> 21 + <a href="/settings/account" class="settings-row"> 22 + <User size={18} /> 23 + <span class="settings-label">Account</span> 24 + <ChevronRight size={16} class="chevron" /> 25 + </a> 10 26 <a href="/settings/profile" class="settings-row"> 11 27 <UserPen size={18} /> 12 28 <span class="settings-label">Edit Profile</span> ··· 22 38 <span class="settings-label">Moderation</span> 23 39 <ChevronRight size={16} class="chevron" /> 24 40 </a> 41 + <a href="/settings/upload-defaults" class="settings-row"> 42 + <Upload size={18} /> 43 + <span class="settings-label">Upload Defaults</span> 44 + <ChevronRight size={16} class="chevron" /> 45 + </a> 25 46 </div> 47 + 48 + <div class="settings-group"> 49 + <a href="/support/privacy" class="settings-row"> 50 + <span class="settings-label">Privacy Policy</span> 51 + <ChevronRight size={16} class="chevron" /> 52 + </a> 53 + <a href="/support/terms" class="settings-row"> 54 + <span class="settings-label">Terms of Service</span> 55 + <ChevronRight size={16} class="chevron" /> 56 + </a> 57 + <a href="/support/copyright" class="settings-row"> 58 + <span class="settings-label">Copyright Policy</span> 59 + <ChevronRight size={16} class="chevron" /> 60 + </a> 61 + <a href="/support/community-guidelines" class="settings-row"> 62 + <span class="settings-label">Community Guidelines</span> 63 + <ChevronRight size={16} class="chevron" /> 64 + </a> 65 + <a href="https://atproto.com" target="_blank" rel="noopener" class="settings-row"> 66 + <span class="settings-label">AT Protocol</span> 67 + <ExternalLink size={14} class="chevron" /> 68 + </a> 69 + </div> 70 + 71 + {#if did} 72 + <div class="settings-group"> 73 + <button class="settings-row sign-out" onclick={doLogout}> 74 + <span class="settings-label">Sign Out</span> 75 + </button> 76 + </div> 77 + {/if} 26 78 </div> 27 79 28 80 <style> ··· 30 82 max-width: 600px; 31 83 margin: 0 auto; 32 84 padding: 16px; 85 + display: flex; 86 + flex-direction: column; 87 + gap: 16px; 33 88 } 34 89 .settings-group { 35 90 border: 1px solid var(--border); ··· 44 99 color: var(--text-primary); 45 100 text-decoration: none; 46 101 transition: background 0.12s; 102 + background: none; 103 + border: none; 104 + width: 100%; 105 + font-family: inherit; 106 + font-size: inherit; 107 + cursor: pointer; 108 + text-align: left; 47 109 } 48 110 .settings-row:not(:last-child) { 49 111 border-bottom: 1px solid var(--border); ··· 57 119 } 58 120 .settings-row :global(.chevron) { 59 121 color: var(--text-muted); 122 + } 123 + .sign-out .settings-label { 124 + color: #f87171; 60 125 } 61 126 </style>
+83
app/routes/settings/account/+page.svelte
··· 1 + <script lang="ts"> 2 + import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 3 + import { ExternalLink } from 'lucide-svelte' 4 + import { viewer } from '$lib/stores' 5 + 6 + const did = $derived($viewer?.did ?? '') 7 + const handle = $derived($viewer?.handle ?? '') 8 + </script> 9 + 10 + <DetailHeader label="Account" /> 11 + 12 + <div class="settings-page"> 13 + <div class="settings-group"> 14 + <div class="settings-row"> 15 + <span class="row-label">Handle</span> 16 + <span class="row-value">@{handle}</span> 17 + </div> 18 + <div class="settings-row"> 19 + <span class="row-label">DID</span> 20 + <span class="row-value did">{did}</span> 21 + </div> 22 + </div> 23 + 24 + {#if did} 25 + <div class="settings-group"> 26 + <a href="https://pdsls.dev/at://{did}" target="_blank" rel="noopener noreferrer" class="settings-row link"> 27 + <span class="row-label">Manage your data</span> 28 + <ExternalLink size={14} class="chevron" /> 29 + </a> 30 + </div> 31 + {/if} 32 + </div> 33 + 34 + <style> 35 + .settings-page { 36 + max-width: 600px; 37 + margin: 0 auto; 38 + padding: 16px; 39 + display: flex; 40 + flex-direction: column; 41 + gap: 16px; 42 + } 43 + .settings-group { 44 + border: 1px solid var(--border); 45 + border-radius: 10px; 46 + overflow: hidden; 47 + } 48 + .settings-row { 49 + display: flex; 50 + align-items: center; 51 + gap: 12px; 52 + padding: 14px 16px; 53 + color: var(--text-primary); 54 + text-decoration: none; 55 + } 56 + .settings-row:not(:last-child) { 57 + border-bottom: 1px solid var(--border); 58 + } 59 + .settings-row.link { 60 + cursor: pointer; 61 + transition: background 0.12s; 62 + } 63 + .settings-row.link:hover { 64 + background: var(--bg-hover); 65 + } 66 + .row-label { 67 + font-size: 15px; 68 + color: var(--text-primary); 69 + } 70 + .row-value { 71 + flex: 1; 72 + text-align: right; 73 + font-size: 14px; 74 + color: var(--text-muted); 75 + } 76 + .row-value.did { 77 + font-size: 11px; 78 + word-break: break-all; 79 + } 80 + .settings-row :global(.chevron) { 81 + color: var(--text-muted); 82 + } 83 + </style>
+2 -43
app/routes/settings/profile/+page.svelte
··· 2 2 import { createQuery, useQueryClient } from '@tanstack/svelte-query' 3 3 import { callXrpc } from '$hatk/client' 4 4 import { viewer } from '$lib/stores' 5 - import { actorProfileQuery, preferencesQuery } from '$lib/queries' 5 + import { actorProfileQuery } from '$lib/queries' 6 6 import { readFileAsDataURL, resizeImage } from '$lib/utils/image-resize' 7 7 import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 8 8 import AvatarCrop from '$lib/components/molecules/AvatarCrop.svelte' ··· 12 12 import Input from '$lib/components/atoms/Input.svelte' 13 13 import RichTextarea from '$lib/components/atoms/RichTextarea.svelte' 14 14 import Toast from '$lib/components/atoms/Toast.svelte' 15 - import Checkbox from '$lib/components/atoms/Checkbox.svelte' 16 - import { setIncludeExif, setIncludeLocation } from '$lib/preferences' 17 15 import { Camera, LoaderCircle, Trash2 } from 'lucide-svelte' 18 16 19 17 let displayName = $state('') ··· 27 25 let saving = $state(false) 28 26 let showToast = $state(false) 29 27 let loaded = $state(false) 30 - let localIncludeExif = $state(true) 31 - let localIncludeLocation = $state(true) 32 28 let fileInput: HTMLInputElement = $state()! 33 29 34 30 const profile = createQuery(() => actorProfileQuery($viewer?.did ?? '')) 35 - const prefs = createQuery(() => preferencesQuery()) 36 31 const queryClient = useQueryClient() 37 32 38 33 $effect(() => { 39 - if (loaded || !profile.data || prefs.isLoading) return 34 + if (loaded || !profile.data) return 40 35 const p = profile.data as any 41 36 displayName = p.displayName || '' 42 37 description = p.description || '' 43 38 avatarPreview = p.avatar || null 44 - if (typeof prefs.data?.includeExif === 'boolean') { 45 - localIncludeExif = prefs.data.includeExif as boolean 46 - } 47 - if (typeof prefs.data?.includeLocation === 'boolean') { 48 - localIncludeLocation = prefs.data.includeLocation as boolean 49 - } 50 39 loaded = true 51 40 }) 52 41 ··· 124 113 record, 125 114 }) 126 115 127 - // Save preferences 128 - await setIncludeExif(localIncludeExif) 129 - await setIncludeLocation(localIncludeLocation) 130 - queryClient.invalidateQueries({ queryKey: ['preferences'] }) 131 116 queryClient.invalidateQueries({ queryKey: ['actorProfile', $viewer.did] }) 132 117 133 118 // Update local viewer store ··· 184 169 </Field> 185 170 </div> 186 171 187 - <div class="preferences"> 188 - <h3 class="preferences-header">Privacy</h3> 189 - <Checkbox bind:checked={localIncludeLocation} label="Include location" /> 190 - <Checkbox bind:checked={localIncludeExif} label="Include camera data" /> 191 - <p class="preferences-note">Camera data includes make, model, and exposure info. Location is auto-detected from photo metadata when available.</p> 192 - </div> 193 - 194 172 <div class="actions"> 195 173 <Button onclick={save} disabled={saving}> 196 174 {#if saving} ··· 256 234 display: flex; 257 235 flex-direction: column; 258 236 gap: 16px; 259 - } 260 - .preferences { 261 - margin-top: 24px; 262 - padding-top: 16px; 263 - border-top: 1px solid var(--border); 264 - } 265 - .preferences-header { 266 - margin: 0 0 8px; 267 - font-size: 13px; 268 - font-weight: 600; 269 - color: var(--text-muted); 270 - text-transform: uppercase; 271 - letter-spacing: 0.04em; 272 - } 273 - .preferences-note { 274 - margin: 8px 0 0; 275 - font-size: 12px; 276 - color: var(--text-muted); 277 - line-height: 1.4; 278 237 } 279 238 .actions { 280 239 margin-top: 24px;
+77
app/routes/settings/upload-defaults/+page.svelte
··· 1 + <script lang="ts"> 2 + import { createQuery } from '@tanstack/svelte-query' 3 + import DetailHeader from '$lib/components/molecules/DetailHeader.svelte' 4 + import Checkbox from '$lib/components/atoms/Checkbox.svelte' 5 + import { setIncludeExif, setIncludeLocation } from '$lib/preferences' 6 + import { preferencesQuery } from '$lib/queries' 7 + 8 + const prefs = createQuery(() => preferencesQuery()) 9 + 10 + let localIncludeLocation = $state(true) 11 + let localIncludeExif = $state(true) 12 + let loaded = $state(false) 13 + 14 + $effect(() => { 15 + if (loaded || prefs.isLoading || !prefs.data) return 16 + if (typeof prefs.data.includeLocation === 'boolean') { 17 + localIncludeLocation = prefs.data.includeLocation as boolean 18 + } 19 + if (typeof prefs.data.includeExif === 'boolean') { 20 + localIncludeExif = prefs.data.includeExif as boolean 21 + } 22 + loaded = true 23 + }) 24 + 25 + $effect(() => { 26 + if (!loaded) return 27 + void setIncludeLocation(localIncludeLocation) 28 + }) 29 + 30 + $effect(() => { 31 + if (!loaded) return 32 + void setIncludeExif(localIncludeExif) 33 + }) 34 + </script> 35 + 36 + <DetailHeader label="Upload Defaults" /> 37 + 38 + <div class="settings-page"> 39 + <div class="settings-group"> 40 + <div class="toggle-row"> 41 + <Checkbox bind:checked={localIncludeLocation} label="Include location" /> 42 + <span class="toggle-desc">Auto-detected from photo metadata</span> 43 + </div> 44 + <div class="toggle-row"> 45 + <Checkbox bind:checked={localIncludeExif} label="Include camera data" /> 46 + <span class="toggle-desc">Make, model, and exposure info</span> 47 + </div> 48 + </div> 49 + </div> 50 + 51 + <style> 52 + .settings-page { 53 + max-width: 600px; 54 + margin: 0 auto; 55 + padding: 16px; 56 + } 57 + .settings-group { 58 + border: 1px solid var(--border); 59 + border-radius: 10px; 60 + overflow: hidden; 61 + } 62 + .toggle-row { 63 + display: flex; 64 + flex-direction: column; 65 + gap: 4px; 66 + padding: 14px 16px; 67 + color: var(--text-primary); 68 + } 69 + .toggle-row:not(:last-child) { 70 + border-bottom: 1px solid var(--border); 71 + } 72 + .toggle-desc { 73 + font-size: 12px; 74 + color: var(--text-muted); 75 + padding-left: 28px; 76 + } 77 + </style>
+23 -5
app/routes/support/privacy/+page.svelte
··· 7 7 <DetailHeader label="Privacy Policy" /> 8 8 9 9 <div class="legal"> 10 - <p class="updated">Last Updated: June 3, 2025</p> 10 + <p class="updated">Last Updated: April 12, 2026</p> 11 11 12 12 <section> 13 13 <h2>Data Storage and Access</h2> ··· 26 26 <section> 27 27 <h2>EXIF Metadata</h2> 28 28 <p> 29 - We optionally collect and display EXIF metadata from your photos. At upload time, you can 30 - choose whether to allow this metadata to be collected. The metadata is stored according to 31 - standard AT Protocol storage mechanisms and is not retained outside the protocol or used for 32 - other purposes. We do not collect GPS or location data from your photos. 29 + We optionally collect and display EXIF metadata from your photos, including camera make, 30 + model, and exposure information. You can control whether camera data is included in your 31 + uploads via Settings &gt; Upload Defaults. The metadata is stored according to standard AT 32 + Protocol storage mechanisms and is not retained outside the protocol or used for other 33 + purposes. 33 34 </p> 34 35 <p> 35 36 You can learn more about the types of metadata commonly embedded in photos at 36 37 <a href="https://exiv2.org/tags.html" target="_blank" rel="noopener noreferrer">exiv2.org</a>. 38 + </p> 39 + </section> 40 + 41 + <section> 42 + <h2>Location Data</h2> 43 + <p> 44 + Grain can optionally extract GPS coordinates from your photo metadata to display location 45 + information on your galleries. Location data is opt-in and can be disabled at any time via 46 + Settings &gt; Upload Defaults. When enabled, GPS coordinates are converted to an 47 + <a href="https://h3geo.org" target="_blank" rel="noopener noreferrer">H3</a> hexagonal grid 48 + index and reverse-geocoded to a human-readable place name. The H3 index and place name are 49 + stored as part of your gallery record on the AT Protocol. Like all content on Grain, location 50 + data attached to galleries is public. 51 + </p> 52 + <p> 53 + We do not collect or track your device location. Location data is derived solely from photo 54 + metadata that you choose to include. 37 55 </p> 38 56 </section> 39 57