Something went wrong. Try again.
grain.social is a photo sharing platform built on atproto. grain.social
appview atproto photography
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214<script lang="ts"> import { callXrpc } from '$hatk/client' import { createQuery } from '@tanstack/svelte-query' import { Flag, LoaderCircle } from 'lucide-svelte' import Modal from '../atoms/Modal.svelte' import Button from '../atoms/Button.svelte' import { labelDefsQuery } from '$lib/labels'
let { subjectUri, subjectCid, variant = 'default', showButton = true, open: openProp = $bindable(false), onopen, onclose, }: { subjectUri: string subjectCid: string variant?: 'default' | 'overlay' showButton?: boolean open?: boolean onopen?: () => void onclose?: () => void } = $props()
let open = $state(false)
$effect(() => { if (openProp && !open) { openModal() openProp = false } }) let label = $state('') let reason = $state('') let submitting = $state(false) let submitted = $state(false) let error = $state<string | null>(null)
const labelsQuery = createQuery(() => labelDefsQuery()) const labelDefs = $derived(labelsQuery.data ?? [])
let wasOpen = false $effect.pre(() => { if (wasOpen && !open) onclose?.() wasOpen = open })
function openModal() { label = labelDefs[0]?.identifier ?? '' reason = '' error = null submitted = false open = true onopen?.() }
function defName(def: { identifier: string; locales?: Array<{ name: string }> }): string { return def.locales?.[0]?.name ?? def.identifier }
async function submit() { if (!label || submitting) return submitting = true error = null try { await callXrpc('dev.hatk.createReport', { subject: { $type: 'com.atproto.repo.strongRef', uri: subjectUri, cid: subjectCid, }, label, ...(reason.trim() ? { reason: reason.trim() } : {}), }) submitted = true } catch (err) { console.error('Report submission failed:', err) error = 'Failed to submit report. Please try again.' } finally { submitting = false } }</script>
{#if showButton}<button class={variant === 'overlay' ? 'overlay-btn' : 'stat'} type="button" onclick={(e) => { e.stopPropagation(); openModal() }} aria-label="Report" title="Report"> <Flag size={variant === 'overlay' ? 20 : 18} /></button>{/if}
<Modal bind:open title={submitted ? 'Report Submitted' : 'Report Content'}> {#if submitted} <p class="success">Thank you. Your report has been submitted for review.</p> <div class="actions"> <Button onclick={() => (open = false)}>Done</Button> </div> {:else} <div class="form"> <label class="field"> <span class="field-label">Category</span> <select bind:value={label}> {#each labelDefs as def} <option value={def.identifier}>{defName(def)}</option> {/each} </select> </label> <label class="field"> <span class="field-label">Details (optional)</span> <textarea bind:value={reason} rows={3} maxlength={2000} placeholder="Provide additional context..."></textarea> </label> {#if error} <p class="error">{error}</p> {/if} <div class="actions"> <Button variant="secondary" onclick={() => (open = false)}>Cancel</Button> <Button onclick={submit} disabled={submitting || !label}> {#if submitting} <LoaderCircle size={14} class="spin" /> Submitting... {:else} Submit Report {/if} </Button> </div> </div> {/if}</Modal>
<style> .stat { display: flex; align-items: center; gap: 6px; background: none; border: none; color: var(--text-primary); cursor: pointer; padding: 0; font-family: inherit; font-size: 13px; transition: opacity 0.15s; } .stat:hover { opacity: 0.7; } .overlay-btn { display: flex; align-items: center; background: none; border: none; color: white; cursor: pointer; padding: 4px; filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5)); } .form { display: flex; flex-direction: column; gap: 16px; } .field { display: flex; flex-direction: column; gap: 6px; } .field-label { font-size: 13px; font-weight: 600; color: var(--text-secondary); } select, textarea { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; color: var(--text-primary); font-family: var(--font-body); font-size: 14px; outline: none; transition: border-color 0.15s; } select:focus, textarea:focus { border-color: var(--grain); } textarea { resize: vertical; min-height: 60px; } .error { color: var(--danger); font-size: 13px; margin: 0; } .success { color: var(--text-secondary); font-size: 14px; margin: 0 0 16px; } .actions { display: flex; justify-content: flex-end; gap: 8px; }</style>