HEX
Server: LiteSpeed
System: Linux houston.panomity.com 6.8.0-100-generic #100-Ubuntu SMP PREEMPT_DYNAMIC Tue Jan 13 16:40:06 UTC 2026 x86_64
User: nudepix (1011)
PHP: 7.4.33
Disabled: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,
Upload Files
File: //tmp/bewertemich-UploadPage-loop.jsx
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { apiFetch } from '../app/api.js';
import { getCaptchaToken } from '../app/captcha.js';
import { AuthModal } from '../auth/AuthModal.jsx';
import { getSessionId, getStoredInviteAttribution, getUploadEntry, getVisitorId, trackEvent } from '../../utils/tracking.js';
import { getPhotoUrl } from '../../utils/imageUrl.js';

export const UploadPage = () => {
  const navigate = useNavigate();
  const location = useLocation();
  const inputRef = useRef(null);
  const [file, setFile] = useState(null);
  const [preview, setPreview] = useState('');
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');
  const [error, setError] = useState('');
  const [website, setWebsite] = useState('');
  const [authOpen, setAuthOpen] = useState(false);
  const lastActionRef = useRef(null); // to retry after auth
  const [dragOver, setDragOver] = useState(false);
  const [heicSupported, setHeicSupported] = useState(null); // null=unknown, boolean otherwise
  const [accepts, setAccepts] = useState('image/jpeg,image/jpg');
  const [showcase, setShowcase] = useState([]);
  const entryPoint = getUploadEntry(location.search);
  const inviteAttribution = getStoredInviteAttribution();

  useEffect(() => {
    void trackEvent('upload_page_view', {
      entryPoint,
      source: inviteAttribution?.source,
      surface: inviteAttribution?.surface,
      meta: inviteAttribution ? { sourcePhotoId: inviteAttribution.sourcePhotoId || '', capturedAt: inviteAttribution.capturedAt || '' } : undefined
    });
  }, [entryPoint]);

  useEffect(() => {
    (async () => {
      try {
        const res = await apiFetch('/api/health');
        const j = await res.json().catch(() => ({}));
        const heic = !!j?.capabilities?.uploads?.heicSupported;
        setHeicSupported(heic);
        setAccepts(heic ? 'image/jpeg,image/jpg,image/heic,image/heif' : 'image/jpeg,image/jpg');
      } catch {
        setHeicSupported(null);
        setAccepts('image/jpeg,image/jpg');
      }
    })();
  }, []);

  useEffect(() => {
    let cancelled = false;
    fetch('/api/photos/feed')
      .then((res) => res.json())
      .then((json) => {
        if (!cancelled) setShowcase((json.items || []).slice(0, 4));
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, []);

  const pickFile = () => inputRef.current?.click();

  const setChosenFile = (f) => {
    if (!f) return;
    // Prevent selecting HEIC when server cannot handle it
    if (heicSupported === false && (f.type === 'image/heic' || f.type === 'image/heif')) {
      setFile(null);
      setPreview('');
      setMsg('');
      setError('HEIC wird auf dem Server derzeit nicht unterstützt. Bitte als JPG hochladen.');
      return;
    }
    setFile(f);
    setPreview(URL.createObjectURL(f));
    setMsg('');
    setError('');
    void trackEvent('upload_file_selected', {
      entryPoint,
      meta: {
        file_type: f.type || 'unknown',
        size_kb: Math.round((Number(f.size) || 0) / 1024)
      }
    });
  };

  const onFileChange = (e) => setChosenFile(e.target.files?.[0]);

  const onDragOver = (e) => {
    e.preventDefault();
    e.stopPropagation();
    setDragOver(true);
  };
  const onDragLeave = (e) => {
    e.preventDefault();
    e.stopPropagation();
    setDragOver(false);
  };
  const onDrop = (e) => {
    e.preventDefault();
    e.stopPropagation();
    setDragOver(false);
    const f = e.dataTransfer?.files?.[0];
    if (f) setChosenFile(f);
  };

  const doUpload = useCallback(async () => {
    if (!file) return;
    setBusy(true);
    setMsg('');
    setError('');
    void trackEvent('upload_submit', {
      entryPoint,
      meta: {
        file_type: file.type || 'unknown'
      }
    });
    const fd = new FormData();
    fd.append('photo', file);
    fd.append('entryPoint', entryPoint);
    if (inviteAttribution?.sourcePhotoId) fd.append('sourcePhotoId', inviteAttribution.sourcePhotoId);
    if (inviteAttribution?.source) fd.append('inviteSource', inviteAttribution.source);
    if (inviteAttribution?.surface) fd.append('inviteSurface', inviteAttribution.surface);
    fd.append('visitorId', getVisitorId());
    fd.append('sessionId', getSessionId());
    if (website) fd.append('website', website);
    const captchaToken = await getCaptchaToken('upload_photo');
    if (captchaToken) fd.append('captchaToken', captchaToken);
    const res = await apiFetch('/api/photos/upload', { method: 'POST', body: fd });
    const data = await res.json().catch(() => ({}));
    setBusy(false);
    if (res.ok) {
      setMsg('Upload erfolgreich!');
      if (data?.photo_id) navigate(`/upload/success/${data.photo_id}?entry=${encodeURIComponent(entryPoint)}`);
      return true;
    }
    // If unauthorized or CSRF failed, prompt auth
    if (res.status === 401 || (res.status === 403 && (data?.error || '').toLowerCase().includes('csrf'))) {
      lastActionRef.current = () => doUpload();
      setAuthOpen(true);
      return false;
    }
    setError(data.error || 'Fehler beim Upload');
    return false;
  }, [entryPoint, file, inviteAttribution?.source, inviteAttribution?.sourcePhotoId, inviteAttribution?.surface, navigate]);

  const onSubmit = async (e) => {
    e.preventDefault();
    if (!file) return;
    lastActionRef.current = null;
    await doUpload();
  };

  const onAuthSuccess = async () => {
    // After auth, retry the last action if any
    const act = lastActionRef.current;
    setAuthOpen(false);
    if (typeof act === 'function') {
      await act();
      lastActionRef.current = null;
    }
  };

  return (
    <div className="mx-auto max-w-5xl px-4 py-10 space-y-6">
      <section className="rounded-3xl border bg-gradient-to-br from-slate-900 via-slate-800 to-rose-700 p-6 text-white shadow-xl md:p-8">
        <div className="badge border-white/20 bg-white/10 text-white">Schritt 1 von 3</div>
        <h1 className="mt-3 text-3xl font-black tracking-tight md:text-4xl">Profilbild hochladen und in Minuten echtes Feedback bekommen</h1>
        <p className="mt-3 max-w-3xl text-white/85">
          Nach dem Upload bekommst du sofort deinen persönlichen Invite-Link. Der schnellste Weg zur ersten klaren Tendenz sind 5 direkte Einladungen an Kontakte, die ehrlich abstimmen.
        </p>
        <div className="mt-6 grid gap-3 md:grid-cols-3">
          <div className="rounded-2xl bg-white/10 p-4 backdrop-blur">
            <div className="text-xs uppercase tracking-[0.2em] text-white/70">1. Hochladen</div>
            <div className="mt-2 text-lg font-bold">Foto wählen</div>
            <div className="mt-1 text-sm text-white/80">JPG bis 10 MB. Du siehst vor dem Upload eine Vorschau.</div>
          </div>
          <div className="rounded-2xl bg-white/10 p-4 backdrop-blur">
            <div className="text-xs uppercase tracking-[0.2em] text-white/70">2. Invite first</div>
            <div className="mt-2 text-lg font-bold">5 Kontakte einladen</div>
            <div className="mt-1 text-sm text-white/80">WhatsApp und Telegram funktionieren in der Regel am schnellsten.</div>
          </div>
          <div className="rounded-2xl bg-white/10 p-4 backdrop-blur">
            <div className="text-xs uppercase tracking-[0.2em] text-white/70">3. Auswerten</div>
            <div className="mt-2 text-lg font-bold">Erstes Signal sehen</div>
            <div className="mt-1 text-sm text-white/80">Ab 5 Stimmen wird die Richtung meist deutlich belastbarer.</div>
          </div>
        </div>
      </section>

      <section className="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
        <form className="space-y-6 rounded-3xl border bg-white p-6 shadow-sm" onSubmit={onSubmit}>
          <div>
            <h2 className="text-2xl font-bold">Dein Upload</h2>
            <p className="mt-2 text-sm text-gray-600">Keine Nacktheit. Bikini- oder Strandfotos sind ok, solange sie nicht explizit sind.</p>
          </div>

          <div
            className={`group relative block w-full rounded-3xl border-2 border-dashed p-6 text-center transition-colors ${dragOver ? 'border-brand bg-pink-50' : 'border-gray-300 hover:border-brand/70'}`}
            onClick={pickFile}
            onDragOver={onDragOver}
            onDragLeave={onDragLeave}
            onDrop={onDrop}
          >
            <input
              ref={inputRef}
              type="file"
              className="hidden"
              accept={accepts}
              onChange={onFileChange}
            />
            <div className="flex flex-col items-center justify-center gap-2">
              <div className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-gray-100 text-gray-600">
                <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-7 w-7"><path d="M12 16a1 1 0 0 1-1-1V9.41l-1.3 1.3a1 1 0 1 1-1.4-1.42l3-3a1 1 0 0 1 1.4 0l3 3a1 1 0 0 1-1.4 1.42L13 9.4V15a1 1 0 0 1-1 1Z"/><path d="M20 12a1 1 0 0 0-2 0 6 6 0 1 1-6-6 1 1 0 0 0 0-2 8 8 0 1 0 8 8Z"/></svg>
              </div>
              <p className="text-sm"><span className="font-medium text-brand">Klicke zum Auswählen</span> oder ziehe dein Bild hierher</p>
              <p className="text-xs text-gray-500">
                {heicSupported === false && 'Nur JPG (HEIC wird nicht unterstützt) • bis 10 MB'}
                {heicSupported === true && 'JPG oder HEIC • HEIC wird automatisch in JPG konvertiert • bis 10 MB'}
                {heicSupported === null && 'Nur JPG (HEIC wird ggf. konvertiert) • bis 10 MB'}
              </p>
            </div>
          </div>
          <div className="absolute left-[-9999px] top-auto h-px w-px overflow-hidden" aria-hidden="true">
            <label htmlFor="upload-website">Website</label>
            <input
              id="upload-website"
              type="text"
              tabIndex={-1}
              autoComplete="off"
              value={website}
              onChange={(e) => setWebsite(e.target.value)}
            />
          </div>

          {preview && (
            <div className="rounded-3xl border bg-base-100 p-4 shadow-sm">
              <div className="flex items-center justify-between gap-3">
                <div>
                  <div className="text-sm font-semibold text-gray-900">Bereit zum Upload</div>
                  <div className="text-xs text-gray-500">Nach dem Upload landest du direkt auf deiner Share- und Ergebnis-Seite.</div>
                </div>
                <div className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">Vorschau aktiv</div>
              </div>
              <img src={preview} alt="Preview" className="mt-4 w-full rounded-2xl border" />
            </div>
          )}

          <div className="flex flex-wrap items-center gap-3">
            <button className={`btn btn-primary ${busy ? 'loading' : ''}`} disabled={busy || !file}>
              {busy ? 'Lädt…' : 'Upload starten'}
            </button>
            <button type="button" className="btn btn-ghost" onClick={pickFile}>Datei wählen</button>
          </div>

          {msg && <div className="alert alert-success text-sm">{msg}</div>}
          {error && <div className="alert alert-error text-sm">{error}</div>}
        </form>

        <aside className="space-y-4">
          <div className="rounded-3xl border bg-white p-5 shadow-sm">
            <h2 className="text-xl font-bold">Was direkt nach dem Upload passiert</h2>
            <ul className="mt-4 space-y-3 text-sm text-gray-700">
              <li className="flex gap-3"><span className="mt-0.5 h-2.5 w-2.5 rounded-full bg-primary" />Du erhältst einen Link mit echter Bildvorschau für WhatsApp, Telegram und Storys.</li>
              <li className="flex gap-3"><span className="mt-0.5 h-2.5 w-2.5 rounded-full bg-secondary" />Du siehst live, wie viele Stimmen schon eingegangen sind.</li>
              <li className="flex gap-3"><span className="mt-0.5 h-2.5 w-2.5 rounded-full bg-emerald-500" />Sobald 5 Stimmen da sind, wird die erste Tendenz deutlich belastbarer.</li>
            </ul>
          </div>

          <div className="rounded-3xl border bg-slate-50 p-5 shadow-sm">
            <h2 className="text-xl font-bold">Was gut funktioniert</h2>
            <div className="mt-4 grid gap-3 text-sm text-gray-700">
              <div className="rounded-2xl bg-white p-4 shadow-sm">
                <div className="font-semibold">Klares Gesicht oder Oberkörper</div>
                <div className="mt-1 text-gray-600">Fotos mit erkennbarem Ausdruck bekommen meist schneller belastbare Bewertungen.</div>
              </div>
              <div className="rounded-2xl bg-white p-4 shadow-sm">
                <div className="font-semibold">Sofort nach Upload teilen</div>
                <div className="mt-1 text-gray-600">Invite first schlägt passives Warten fast immer bei den ersten Stimmen.</div>
              </div>
              <div className="rounded-2xl bg-white p-4 shadow-sm">
                <div className="font-semibold">Ein Foto pro Aussage</div>
                <div className="mt-1 text-gray-600">Teste lieber mehrere klare Varianten nacheinander statt ein unruhiges Mischbild.</div>
              </div>
            </div>
          </div>

          {showcase.length > 0 ? (
            <div className="rounded-3xl border bg-white p-5 shadow-sm">
              <h2 className="text-xl font-bold">Aktuelle Foto-Referenzen</h2>
              <div className="mt-2 text-sm text-gray-600">Ein paar Beispiele aus dem aktuellen Feed. Die Seite wirkt sofort lebendiger, wenn echte Bilder sichtbar sind.</div>
              <div className="mt-4 grid grid-cols-2 gap-3">
                {showcase.map((item) => (
                  <img key={item.id} src={getPhotoUrl(item)} alt="Feed Referenz" className="aspect-square w-full rounded-2xl object-cover" />
                ))}
              </div>
            </div>
          ) : null}
        </aside>
      </section>

      <AuthModal open={authOpen} onClose={() => setAuthOpen(false)} onSuccess={onAuthSuccess} />
    </div>
  );
};