Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

7.8 kB Ruw bestand
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>
  );
}