Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { useEffect, useRef, useState } from "react";
import { INK, RED, BLUE, PAPER, SKY, MUTE, FONT_MONO, FONT_SERIF } from "../../theme";
import { MIN_CHARS, PHOTO_ALT_MAX } from "../../constants";
import { preparePhoto } from "../../lib/image";
import PhotoFrame from "../../components/PhotoFrame";
/*
* Brief schrijven: minimumlengte + hoogstens één foto. Dit is een tabblad van de correspondentie,
* geen dialoog — je kunt ernaast blijven lezen en zoeken zonder je concept kwijt te raken. Daarom
* blijft dit paneel gemonteerd terwijl je op het andere tabblad kijkt (`active` zet alleen display),
* en gooit alleen ThreadView het concept weg, door de sleutel te verversen na verzenden.
*
* canPhoto = false zolang de ander mij nog nooit geschreven heeft: dan is er geen fotokiezer, alleen
* een regel die zegt waarom. Ongevraagde post is tekst. De server weigert zo'n foto ook (dit is niet
* de enige slotgracht), maar dan is hij al gekozen en verkleind — vandaar dat de knop hier wegblijft.
*
* onSend(text, photo) → Promise, waarbij photo null is of { data, mime, width, height, alt } — al
* verkleind en van EXIF ontdaan (zie lib/image.js). De beschrijving is verplicht: zonder gaat de
* brief niet weg. onDraftChange(bool) laat ThreadView weten of er iets te verliezen valt.
*/
export default function ComposePane({ recipient, canPhoto = false, active, onSend, onDraftChange }) {
const [text, setText] = useState("");
const [photo, setPhoto] = useState(null); // { data, mime, width, height, previewUrl }
const [alt, setAlt] = useState("");
const [loadingPhoto, setLoadingPhoto] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
const fileInput = useRef(null);
const left = MIN_CHARS - text.length;
const describedPhoto = !photo || alt.trim().length > 0;
const ready = left <= 0 && !sending && !loadingPhoto && describedPhoto;
/*
* Is er een begonnen brief? App vraagt daarop om bevestiging bij het verlaten. Het opruimen zet
* het weer op false — ook als dit paneel verdwijnt (na verzenden, of bij een andere correspondent),
* zodat er geen concept blijft rondspoken dat er niet meer is.
*/
const hasDraft = Boolean(text.trim() || photo);
useEffect(() => {
onDraftChange?.(hasDraft);
return () => onDraftChange?.(false);
}, [hasDraft, onDraftChange]);
/* De blob-URL van het voorbeeld weer intrekken zodra de foto weg is of het paneel verdwijnt */
const dropPhoto = () => {
if (photo) URL.revokeObjectURL(photo.previewUrl);
setPhoto(null);
setAlt("");
};
useEffect(() => () => { if (photo) URL.revokeObjectURL(photo.previewUrl); }, [photo]);
/* Een gekozen bestand verkleinen en hercoderen; fouten komen uit lib/image.js met een leesbare tekst */
const pickPhoto = async (file) => {
if (!file) return;
setLoadingPhoto(true);
setError(null);
try {
const prepared = await preparePhoto(file);
dropPhoto();
setPhoto(prepared);
} catch (e) {
setError(e.message);
} finally {
setLoadingPhoto(false);
if (fileInput.current) fileInput.current.value = ""; // dezelfde foto opnieuw kiezen mag
}
};
const submit = async () => {
if (!ready) return;
setSending(true);
setError(null);
try {
await onSend(text, photo && { ...photo, alt });
// gelukt: ThreadView springt terug naar de brieven en ververst dit paneel
} catch (e) {
setError(e.message);
setSending(false);
}
};
return (
<main className="app-main" style={{ display: active ? "block" : "none", paddingBottom: 20 }}>
<p style={{ fontFamily: FONT_MONO, fontSize: 11, color: MUTE, margin: "0 0 12px" }}>
aan {recipient.name} · minimaal {MIN_CHARS} tekens{canPhoto && " · maximaal één foto"}
</p>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={9}
placeholder={`Beste ${recipient.name},`}
aria-label="Brieftekst"
style={{
width: "100%", padding: 14, fontSize: 15.5, fontFamily: FONT_SERIF, lineHeight: 1.7,
border: `2px solid ${INK}`, background: PAPER, resize: "vertical",
}}
/>
<p
aria-live="polite"
style={{ fontFamily: FONT_MONO, fontSize: 12, margin: "6px 0 14px", color: left <= 0 ? BLUE : RED, fontWeight: 500 }}
>
{left <= 0
? `✓ ${text.length} tekens — lang genoeg om te versturen`
: `nog ${left} tekens te gaan (${text.length}/${MIN_CHARS})`}
</p>
{/* Nog niets van de ander gehoord: geen kiezer, wel de reden. Eén regel, geen waarschuwing —
het is geen fout van de schrijver maar hoe de post hier werkt. */}
{!canPhoto && (
<p style={{ fontFamily: FONT_MONO, fontSize: 11, color: MUTE, lineHeight: 1.6, margin: "0 0 16px" }}>
In een eerste brief gaat nog geen foto mee. Zodra {recipient.name} je geschreven heeft, kan dat wel.
</p>
)}
{/* De kiezer zelf is een gewoon bestandsveld: in de app opent dat de galerij of de camera */}
{canPhoto && (
<input
ref={fileInput}
type="file"
accept="image/*"
onChange={(e) => pickPhoto(e.target.files?.[0])}
style={{ display: "none" }}
/>
)}
{canPhoto && !photo && (
<div style={{ marginBottom: 16 }}>
<button
onClick={() => fileInput.current?.click()}
disabled={loadingPhoto}
style={{
background: "#fff", color: INK, border: `2px dashed ${INK}`, padding: "10px 16px",
fontSize: 14, fontWeight: 600,
}}
>
{loadingPhoto ? "Foto voorbereiden…" : "+ Voeg één foto toe"}
</button>
<p style={{ fontFamily: FONT_MONO, fontSize: 11, color: MUTE, margin: "8px 0 0" }}>
De foto wordt op je toestel verkleind; locatie- en cameragegevens gaan er niet mee.
</p>
</div>
)}
{photo && (
<div style={{ marginBottom: 16 }}>
<PhotoFrame src={photo.previewUrl} alt={alt} caption="" width="60%" />
<label
htmlFor="foto-beschrijving"
style={{ display: "block", fontSize: 13, fontWeight: 600, margin: "12px 0 4px" }}
>
Wat staat er op de foto?
</label>
<input
id="foto-beschrijving"
value={alt}
onChange={(e) => setAlt(e.target.value.slice(0, PHOTO_ALT_MAX))}
placeholder="Mijn boekenkast, onderste plank"
style={{
width: "100%", padding: "10px 12px", fontSize: 14, fontFamily: FONT_SERIF,
border: `2px solid ${INK}`, background: PAPER,
}}
/>
<p style={{ fontFamily: FONT_MONO, fontSize: 11, color: describedPhoto ? MUTE : RED, margin: "6px 0 0" }}>
{describedPhoto
? `komt als bijschrift onder de foto · nog ${PHOTO_ALT_MAX - alt.length} tekens`
: "een beschrijving is verplicht — ze staat als bijschrift onder de foto"}
</p>
<button
onClick={dropPhoto}
style={{ background: "none", border: "none", color: RED, fontWeight: 600, fontSize: 13, padding: "8px 0 0" }}
>
Foto verwijderen
</button>
</div>
)}
{error && (
<p role="alert" style={{ fontFamily: FONT_MONO, fontSize: 12, color: RED, margin: "0 0 10px" }}>fout: {error}</p>
)}
<button
onClick={submit}
disabled={!ready}
style={{
width: "100%", padding: "14px 0", fontSize: 16, fontWeight: 700, border: "none",
background: ready ? RED : SKY, color: ready ? "#fff" : MUTE,
}}
>
{sending ? "Versturen…" : "✉ Verstuur brief"}
</button>
</main>
);
}