Montag, 25 August 2025

Top 5 diese Woche

Ähnliche Tutorials

Cannapower Klon

Dieses Script ist eine komplette PHP-Webanwendung für legale Datei-Downloads. Nutzer können Dateien hochladen, nach Kategorien durchsuchen, Kommentare hinterlassen, Inhalte bewerten und melden. Admins und Moderatoren prüfen Uploads vor der Freigabe.

Hauptfunktionen:

  • Registrierung, Login, E-Mail-Verifikation
  • Upload von Dateien mit Kategorie, Beschreibung und Rechtsbestätigung
  • Suche, Sortierung, Voting und Kommentare
  • Moderation: Freigabe/Ablehnung von Uploads
  • Rollenverwaltung: Admin, Moderator, User
  • Speicherung lokal oder optional S3
  • SQLite oder MySQL als Datenbank
  • Modernes Frontend mit Tailwind CSS und responsivem Layout
  • Sicherheit: CSRF-Schutz, Passwort-Hashing, Session-Handling

Installations Anleitung:

  1. Lege die Datei auf deinen Server (PHP 8.1+, curl aktiviert).
  2. Ordner uploads/ anlegen und mit Schreibrechten versehen (wenn STORAGE='local' bleibt).
  3. Rufe ?api=init einmal im Browser auf → DB wird erstellt (SQLite).
  4. Registriere dich; in der DB kannst du deinen User auf role='admin' setzen, um Moderation & Stats zu sehen.
  5. Mailversand nutzt mail(); trage MAIL_FROM ein oder hänge später SMTP an.
  6. Optional S3: trage Bucket/Region/Keys ein und setze STORAGE='s3'.

Perfekt für Websites, die legale Inhalte zum Download anbieten wollen, mit fertiger Benutzerverwaltung und moderierten Uploads.

<?php
/**
 * Dreamcodes Legal Downloads — single‑file PHP app
 * Features: Uploads (legal only), categories, search, votes, comments, moderation queue,
 * abuse reports, roles (Admin/Moderator/User), email verification, CSRF, optional SQLite,
 * local storage or S3-ready hook, Tailwind UI.
 *
 * IMPORTANT: Intended for legally shareable content only.
 */

/* ========================= CONFIG ========================= */
const APP_NAME = 'DreamCodes Legal Downloads';
const APP_BRAND_HTML = '<a href="https://dreamcodes.net" class="hover:underline" target="_blank" rel="noopener">Dreamcodes</a>'; // as requested

// Database: set either MySQL or SQLite
const DB_DRIVER = 'sqlite'; // 'mysql' or 'sqlite'
const DB_SQLITE_PATH = __DIR__ . '/dreamcodes.db';
const DB_HOST = '127.0.0.1';
const DB_PORT = '3306';
const DB_NAME = 'dreamcodes';
const DB_USER = 'db_name';
const DB_PASS = '';

// Storage
const STORAGE = 'local'; // 'local' or 's3'
const LOCAL_UPLOAD_DIR = __DIR__ . '/uploads';
const PUBLIC_UPLOAD_BASE = '/uploads'; // web path for local files

// S3 (optional; provide bucket+region+keys; simple v4 signing implemented for putObject)
const S3_BUCKET = '';
const S3_REGION = '';
const S3_ACCESS_KEY = '';
const S3_SECRET_KEY = '';
const S3_PUBLIC_BASE = ''; // e.g. https://<bucket>.s3.<region>.amazonaws.com

// Mail (verification). Uses PHP mail() by default.
const MAIL_FROM = 'no-reply@yourdomain.tld';
const MAIL_SENDER = 'Portal Seite';

// Security
const SESSION_NAME = 'dreamcodes_sess';
const CSRF_KEY = 'dreamcodes_csrf';
const MAX_UPLOAD_MB = 50; // server must allow it as well

/* ========================= BOOTSTRAP ========================= */
session_name(SESSION_NAME);
session_start();
header('X-Frame-Options: SAMEORIGIN');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');

// Helpers
function db(): PDO {
  static $pdo; if ($pdo) return $pdo;
  if (DB_DRIVER === 'sqlite') {
    $pdo = new PDO('sqlite:' . DB_SQLITE_PATH);
  } else {
    $dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=utf8mb4';
    $pdo = new PDO($dsn, DB_USER, DB_PASS, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
  }
  $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  return $pdo;
}
function json($data, $code=200){ http_response_code($code); header('Content-Type: application/json'); echo json_encode($data); exit; }
function input($key,$default=null){ return $_POST[$key] ?? $_GET[$key] ?? $default; }
function uid(){ return bin2hex(random_bytes(16)); }
function now(){ return (new DateTimeImmutable())->format('Y-m-d H:i:s'); }
function me(){ return $_SESSION['user'] ?? null; }
function isRole($role){ $u=me(); return $u && ($u['role']===$role || $u['role']==='admin'); }
function ensure($cond,$msg='Forbidden',$code=403){ if(!$cond){ json(['ok'=>false,'error'=>$msg],$code);} }
function csrf_token(){ if(empty($_SESSION[CSRF_KEY])) $_SESSION[CSRF_KEY]=bin2hex(random_bytes(16)); return $_SESSION[CSRF_KEY]; }
function csrf_ok(){ return ($_SERVER['REQUEST_METHOD']!=='GET') && !empty($_POST['csrf']) && hash_equals($_SESSION[CSRF_KEY]??'', $_POST['csrf']); }

/* ========================= MIGRATIONS ========================= */
function migrate(){
  $pdo=db();
  $pdo->exec('CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT UNIQUE NOT NULL,
    pass TEXT NOT NULL,
    name TEXT,
    role TEXT NOT NULL DEFAULT "user",
    email_verified INTEGER NOT NULL DEFAULT 0,
    verify_token TEXT,
    created_at TEXT NOT NULL
  )');
  $pdo->exec('CREATE TABLE IF NOT EXISTS categories (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT UNIQUE NOT NULL
  )');
  $pdo->exec('CREATE TABLE IF NOT EXISTS items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    uid TEXT UNIQUE NOT NULL,
    title TEXT NOT NULL,
    description TEXT,
    category_id INTEGER,
    file_url TEXT NOT NULL,
    file_size INTEGER,
    status TEXT NOT NULL DEFAULT "pending", -- pending|approved|rejected
    user_id INTEGER,
    created_at TEXT NOT NULL,
    FOREIGN KEY(category_id) REFERENCES categories(id),
    FOREIGN KEY(user_id) REFERENCES users(id)
  )');
  $pdo->exec('CREATE TABLE IF NOT EXISTS votes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    item_id INTEGER NOT NULL,
    user_id INTEGER NOT NULL,
    value INTEGER NOT NULL, -- +1/-1
    created_at TEXT NOT NULL,
    UNIQUE(item_id,user_id)
  )');
  $pdo->exec('CREATE TABLE IF NOT EXISTS comments (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    item_id INTEGER NOT NULL,
    user_id INTEGER NOT NULL,
    body TEXT NOT NULL,
    created_at TEXT NOT NULL
  )');
  $pdo->exec('CREATE TABLE IF NOT EXISTS reports (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    item_id INTEGER NOT NULL,
    user_id INTEGER,
    reason TEXT NOT NULL,
    created_at TEXT NOT NULL,
    resolved INTEGER NOT NULL DEFAULT 0
  )');
  // seeds
  $pdo->exec("INSERT OR IGNORE INTO categories(id,name) VALUES
    (1,'Musik'),(2,'Podcasts'),(3,'Hörbücher'),(4,'Software'),(5,'Dokumente')");
}

/* ========================= STORAGE ========================= */
function ensure_upload_dir(){ if(!is_dir(LOCAL_UPLOAD_DIR)) @mkdir(LOCAL_UPLOAD_DIR,0775,true); }
function store_file(array $file): array {
  if (STORAGE === 'local') {
    ensure_upload_dir();
    $ext = pathinfo($file['name'], PATHINFO_EXTENSION);
    $name = uid() . ($ext?'.'.strtolower($ext):'');
    $dest = LOCAL_UPLOAD_DIR . '/' . $name;
    if(!move_uploaded_file($file['tmp_name'],$dest)) throw new RuntimeException('Upload fehlgeschlagen');
    return ['url'=> rtrim(PUBLIC_UPLOAD_BASE,'/') . '/' . $name, 'size'=> filesize($dest)];
  }
  // S3 minimal: direct putObject with v4 signing (no SDK)
  if (STORAGE === 's3') {
    if(!S3_BUCKET || !S3_REGION || !S3_ACCESS_KEY || !S3_SECRET_KEY || !S3_PUBLIC_BASE){
      throw new RuntimeException('S3 ist nicht konfiguriert');
    }
    $ext = pathinfo($file['name'], PATHINFO_EXTENSION);
    $key = 'uploads/'.uid().($ext?'.'.strtolower($ext):'');
    $body = file_get_contents($file['tmp_name']);
    $mime = $file['type'] ?: 'application/octet-stream';
    s3_put_object($key, $body, $mime);
    return ['url'=> rtrim(S3_PUBLIC_BASE,'/') . '/' . $key, 'size'=> strlen($body)];
  }
  throw new RuntimeException('Unbekannter Speicher');
}
function s3_put_object($key,$body,$mime){
  $service='s3'; $host=S3_BUCKET.'.s3.'.S3_REGION.'.amazonaws.com'; $region=S3_REGION; $endpoint='https://'.$host.'/'.$key;
  $method='PUT'; $t=new DateTime('now', new DateTimeZone('UTC'));
  $amzdate=$t->format('Ymd\THis\Z'); $datestamp=$t->format('Ymd');
  $payloadHash=hash('sha256',$body);
  $canonicalHeaders="host:$host\nx-amz-content-sha256:$payloadHash\nx-amz-date:$amzdate\n";
  $signedHeaders='host;x-amz-content-sha256;x-amz-date';
  $canonicalRequest="$method\n/".str_replace('%2F','/',rawurlencode($key))."\n\n$canonicalHeaders\n$signedHeaders\n$payloadHash";
  $algorithm='AWS4-HMAC-SHA256';
  $credentialScope="$datestamp/$region/$service/aws4_request";
  $stringToSign="$algorithm\n$amzdate\n$credentialScope\n".hash('sha256',$canonicalRequest);
  $kDate=hash_hmac('sha256',$datestamp,'AWS4'.S3_SECRET_KEY, true);
  $kRegion=hash_hmac('sha256',$region,$kDate,true);
  $kService=hash_hmac('sha256',$service,$kRegion,true);
  $kSigning=hash_hmac('sha256','aws4_request',$kService,true);
  $signature=hash_hmac('sha256',$stringToSign,$kSigning);
  $authorization="AWS4-HMAC-SHA256 Credential=".S3_ACCESS_KEY."/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature";
  $headers=[
    'Authorization: '.$authorization,
    'x-amz-date: '.$amzdate,
    'x-amz-content-sha256: '.$payloadHash,
    'Content-Type: '.$mime,
    'Content-Length: '.strlen($body)
  ];
  $ch=curl_init($endpoint); curl_setopt_array($ch,[CURLOPT_CUSTOMREQUEST=>'PUT',CURLOPT_HTTPHEADER=>$headers,CURLOPT_POSTFIELDS=>$body,CURLOPT_RETURNTRANSFER=>true]);
  $res=curl_exec($ch); $code=curl_getinfo($ch,CURLINFO_HTTP_CODE); if($code>=300){ throw new RuntimeException('S3 Error: '.$code.' '.$res); }
  curl_close($ch);
}

/* ========================= AUTH ========================= */
function register(){
  ensure(csrf_ok(),'Bad CSRF',400);
  $email=filter_var(trim($_POST['email']??''), FILTER_VALIDATE_EMAIL) ?: json(['ok'=>false,'error'=>'Ungültige E-Mail'],422);
  $pass=trim($_POST['pass']??''); ensure(strlen($pass)>=6,'Passwort zu kurz',422);
  $name=trim($_POST['name']??'');
  $token=uid();
  $stmt=db()->prepare('INSERT INTO users(email,pass,name,verify_token,created_at) VALUES(?,?,?,?,?)');
  try{ $stmt->execute([$email,password_hash($pass,PASSWORD_DEFAULT),$name,$token,now()]); }
  catch(Throwable $e){ json(['ok'=>false,'error'=>'E-Mail existiert bereits'],409); }
  send_verification_mail($email,$token);
  json(['ok'=>true]);
}
function login(){
  ensure(csrf_ok(),'Bad CSRF',400);
  $email=trim($_POST['email']??''); $pass=$_POST['pass']??'';
  $u=db()->prepare('SELECT * FROM users WHERE email=?')->execute([$email])->fetch(PDO::FETCH_ASSOC);
  if(!$u || !password_verify($pass,$u['pass'])) json(['ok'=>false,'error'=>'Login fehlgeschlagen'],401);
  $_SESSION['user']=['id'=>$u['id'],'email'=>$u['email'],'name'=>$u['name'],'role'=>$u['role'],'email_verified'=>$u['email_verified']];
  json(['ok'=>true,'user'=>$_SESSION['user']]);
}
function logout(){ session_destroy(); json(['ok'=>true]); }
function verify_email(){
  $token=input('token'); $st=db()->prepare('UPDATE users SET email_verified=1, verify_token=NULL WHERE verify_token=?');
  $st->execute([$token]);
  $ok=$st->rowCount()>0; echo '<meta charset="utf-8"><div style="padding:2rem;font:16px system-ui">'.($ok?'E-Mail verifiziert. Sie können das Fenster schließen.':'Link ungültig.').'</div>';
  exit;
}
function send_verification_mail($email,$token){
  $link = current_origin().current_path().'?api=verify_email&token='.urlencode($token);
  $subject='Bitte E-Mail bestätigen';
  $msg="Hallo,\n\nbitte bestätige deine E-Mail: $link\n\nDanke,\n".MAIL_SENDER;
  @mail($email, $subject, $msg, 'From: '.MAIL_SENDER.' <'.MAIL_FROM.'>');
}
function current_origin(){ $scheme=(!empty($_SERVER['HTTPS'])&&$_SERVER['HTTPS']!=='off')?'https':'http'; return $scheme.'://'.$_SERVER['HTTP_HOST']; }
function current_path(){ return strtok($_SERVER['REQUEST_URI'],'?'); }

/* ========================= ITEMS ========================= */
function upload(){
  ensure(csrf_ok(),'Bad CSRF',400);
  $u=me(); ensure($u,'Login erforderlich',401);
  ensure(($u['email_verified']??0)==1,'E-Mail noch nicht verifiziert',403);
  ensure(!empty($_POST['legal_ok']),'Nur mit Rechten hochladen',422);
  $title=trim($_POST['title']??''); ensure($title!=='','Titel fehlt',422);
  $desc=trim($_POST['description']??'');
  $cat=(int)($_POST['category_id']??0); if(!$cat) $cat=null;
  if(empty($_FILES['file'])||$_FILES['file']['error']!==UPLOAD_ERR_OK) json(['ok'=>false,'error'=>'Datei fehlt'],422);
  $sizeMB = ($_FILES['file']['size']??0)/1048576; ensure($sizeMB<=MAX_UPLOAD_MB,'Datei zu groß (max '.MAX_UPLOAD_MB.'MB)',422);
  $stored=store_file($_FILES['file']);
  $stmt=db()->prepare('INSERT INTO items(uid,title,description,category_id,file_url,file_size,status,user_id,created_at) VALUES(?,?,?,?,?,?,"pending",?,?)');
  $stmt->execute([uid(),$title,$desc,$cat,$stored['url'],$stored['size'],$u['id'],now()]);
  json(['ok'=>true,'message'=>'Upload eingereicht. Wartet auf Freigabe.']);
}
function list_items(){
  $q=trim(input('q','')); $cat=(int)input('category_id',0); $sort=input('sort','new');
  $sql='SELECT i.*, COALESCE(SUM(v.value),0) score, c.name cat_name, u.name author FROM items i
        LEFT JOIN votes v ON v.item_id=i.id
        LEFT JOIN categories c ON c.id=i.category_id
        LEFT JOIN users u ON u.id=i.user_id
        WHERE i.status="approved"';
  $params=[];
  if($q!==''){ $sql.=' AND (i.title LIKE ? OR i.description LIKE ?)'; $params=['%'.$q.'%','%'.$q.'%']; }
  if($cat){ $sql.=' AND i.category_id=?'; $params[]=$cat; }
  $sql.=' GROUP BY i.id ';
  if($sort==='top') $sql.=' ORDER BY score DESC, i.id DESC'; else $sql.=' ORDER BY i.id DESC';
  $sql.=' LIMIT 100';
  $st=db()->prepare($sql); $st->execute($params); $rows=$st->fetchAll(PDO::FETCH_ASSOC);
  json(['ok'=>true,'items'=>$rows]);
}
function item_detail(){
  $uid=input('uid'); $st=db()->prepare('SELECT i.*, COALESCE(SUM(v.value),0) score, c.name cat_name, u.name author
    FROM items i LEFT JOIN votes v ON v.item_id=i.id LEFT JOIN categories c ON c.id=i.category_id LEFT JOIN users u ON u.id=i.user_id WHERE i.uid=? GROUP BY i.id');
  $st->execute([$uid]); $it=$st->fetch(PDO::FETCH_ASSOC); if(!$it) json(['ok'=>false],404);
  $cm=db()->prepare('SELECT c.*, u.name author FROM comments c LEFT JOIN users u ON u.id=c.user_id WHERE c.item_id=? ORDER BY c.id DESC');
  $cm->execute([$it['id']]); $comments=$cm->fetchAll(PDO::FETCH_ASSOC);
  json(['ok'=>true,'item'=>$it,'comments'=>$comments]);
}
function vote(){ ensure(csrf_ok(),'Bad CSRF',400); $u=me(); ensure($u, 'Login erforderlich',401);
  $uid=input('uid'); $val=(int)($_POST['value']??0); $val=$val>0?1:-1;
  $it=db()->prepare('SELECT id FROM items WHERE uid=? AND status="approved"')->execute([$uid])->fetch(PDO::FETCH_ASSOC); if(!$it) json(['ok'=>false],404);
  $st=db()->prepare('INSERT INTO votes(item_id,user_id,value,created_at) VALUES(?,?,?,?) ON CONFLICT(item_id,user_id) DO UPDATE SET value=excluded.value');
  $st->execute([$it['id'],$u['id'],$val,now()]); json(['ok'=>true]); }
function comment(){ ensure(csrf_ok(),'Bad CSRF',400); $u=me(); ensure($u, 'Login erforderlich',401);
  $uid=input('uid'); $body=trim($_POST['body']??''); ensure($body!=='','Kommentar leer',422);
  $it=db()->prepare('SELECT id FROM items WHERE uid=? AND status="approved"')->execute([$uid])->fetch(PDO::FETCH_ASSOC); if(!$it) json(['ok'=>false],404);
  db()->prepare('INSERT INTO comments(item_id,user_id,body,created_at) VALUES(?,?,?,?)')->execute([$it['id'],$u['id'],$body,now()]);
  json(['ok'=>true]); }
function report(){ ensure(csrf_ok(),'Bad CSRF',400); $u=me(); $uid=input('uid'); $reason=trim($_POST['reason']??''); ensure($reason!=='','Grund fehlt',422);
  $it=db()->prepare('SELECT id FROM items WHERE uid=?')->execute([$uid])->fetch(PDO::FETCH_ASSOC); if(!$it) json(['ok'=>false],404);
  db()->prepare('INSERT INTO reports(item_id,user_id,reason,created_at) VALUES(?,?,?,?)')->execute([$it['id'],$u['id']??null,$reason,now()]);
  json(['ok'=>true]); }

/* ========================= MODERATION (Admin/Moderator) ========================= */
function mod_list(){ ensure(isRole('moderator')||isRole('admin'),'Nope',403);
  $st=db()->query('SELECT i.*, u.email submitter, c.name cat_name FROM items i LEFT JOIN users u ON u.id=i.user_id LEFT JOIN categories c ON c.id=i.category_id WHERE i.status="pending" ORDER BY i.id ASC LIMIT 200');
  json(['ok'=>true,'items'=>$st->fetchAll(PDO::FETCH_ASSOC)]);
}
function mod_action(){ ensure(csrf_ok(),'Bad CSRF',400); ensure(isRole('moderator')||isRole('admin'),'Nope',403);
  $uid=input('uid'); $act=input('action'); ensure(in_array($act,['approve','reject'],true),'act',422);
  $st=db()->prepare('UPDATE items SET status=? WHERE uid=?'); $st->execute([$act==='approve'?'approved':'rejected',$uid]);
  json(['ok'=>true]); }
function admin_stats(){ ensure(isRole('admin'),'Nope',403);
  $d=db();
  $stats=[
    'users'=>$d->query('SELECT COUNT(*) FROM users')->fetchColumn(),
    'pending'=>$d->query('SELECT COUNT(*) FROM items WHERE status="pending"')->fetchColumn(),
    'approved'=>$d->query('SELECT COUNT(*) FROM items WHERE status="approved"')->fetchColumn(),
    'reports'=>$d->query('SELECT COUNT(*) FROM reports WHERE resolved=0')->fetchColumn(),
  ]; json(['ok'=>true,'stats'=>$stats]);
}

/* ========================= ROUTER ========================= */
$api = $_GET['api'] ?? null;
if ($api==='init'){ migrate(); echo '<meta charset="utf-8"><div style="padding:1rem;font:14px system-ui">DB ready.</div>'; exit; }
if ($api==='register') register();
if ($api==='login') login();
if ($api==='logout') logout();
if ($api==='verify_email') verify_email();
if ($api==='upload') upload();
if ($api==='list') list_items();
if ($api==='item') item_detail();
if ($api==='vote') vote();
if ($api==='comment') comment();
if ($api==='report') report();
if ($api==='mod_list') mod_list();
if ($api==='mod_action') mod_action();
if ($api==='admin_stats') admin_stats();

/* ========================= UI ========================= */
?>
<!doctype html>
<html lang="de">
<head>
  <meta charset="utf-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
  <title>Dreamcodes - <?=htmlspecialchars(APP_NAME)?></title>
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    :root { color-scheme: dark; }
    .card{ @apply rounded-2xl border border-white/10 bg-slate-900/50 shadow-xl; }
    .btn{ @apply inline-flex items-center justify-center rounded-xl px-4 py-2 font-semibold transition; }
    .btn-primary{ @apply bg-sky-500 text-black hover:bg-sky-400; }
    .btn-ghost{ @apply bg-white/5 hover:bg-white/10; }
    .chip{ @apply rounded-full px-2 py-0.5 text-xs bg-white/10; }
  </style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
  <header class="border-b border-white/10 sticky top-0 backdrop-blur bg-slate-950/70 z-20">
    <div class="max-w-6xl mx-auto px-4 h-14 flex items-center justify-between">
      <div class="font-extrabold"><?=htmlspecialchars(APP_NAME)?></div>
      <nav class="hidden sm:flex items-center gap-2">
        <button id="nav-home" class="btn btn-ghost">Start</button>
        <button id="nav-upload" class="btn btn-ghost">Upload</button>
        <button id="nav-pending" class="btn btn-ghost hidden">Moderation</button>
      </nav>
      <div id="auth-box" class="flex items-center gap-2"></div>
    </div>
  </header>

  <main class="max-w-6xl mx-auto px-4 py-6 space-y-6">
    <!-- Search & filters -->
    <section class="card p-4">
      <div class="grid sm:grid-cols-4 gap-3">
        <input id="q" placeholder="Suchen…" class="col-span-2 bg-slate-800/70 rounded-xl px-3 py-2"/>
        <select id="cat" class="bg-slate-800/70 rounded-xl px-3 py-2">
          <option value="0">Alle Kategorien</option>
          <option value="1">Musik</option>
          <option value="2">Podcasts</option>
          <option value="3">Hörbücher</option>
          <option value="4">Software</option>
          <option value="5">Dokumente</option>
        </select>
        <select id="sort" class="bg-slate-800/70 rounded-xl px-3 py-2">
          <option value="new">Neueste</option>
          <option value="top">Top</option>
        </select>
      </div>
    </section>

    <!-- List -->
    <section id="list" class="grid md:grid-cols-2 gap-4"></section>

    <!-- Upload -->
    <section id="upload" class="card p-4 hidden">
      <form id="uploadForm" class="space-y-3">
        <input type="hidden" name="csrf" value="<?=htmlspecialchars(csrf_token())?>"/>
        <div class="grid sm:grid-cols-2 gap-3">
          <input name="title" class="bg-slate-800/70 rounded-xl px-3 py-2" placeholder="Titel" required>
          <select name="category_id" class="bg-slate-800/70 rounded-xl px-3 py-2">
            <option value="1">Musik</option><option value="2">Podcasts</option><option value="3">Hörbücher</option><option value="4">Software</option><option value="5">Dokumente</option>
          </select>
        </div>
        <textarea name="description" class="w-full bg-slate-800/70 rounded-xl px-3 py-2" rows="3" placeholder="Beschreibung (Lizenz, Quelle, Version…)"></textarea>
        <input type="file" name="file" class="block w-full text-sm" required>
        <label class="flex items-center gap-2 text-sm"><input type="checkbox" name="legal_ok" required>Ich bestätige, dass ich die Rechte am Inhalt habe.</label>
        <button class="btn btn-primary">Einreichen</button>
      </form>
      <p class="text-xs text-slate-400 mt-2">Uploads werden von Moderatoren geprüft.</p>
    </section>

    <!-- Moderation -->
    <section id="pending" class="card p-4 hidden">
      <h2 class="font-bold mb-2">Warteschlange</h2>
      <div id="pendingList" class="space-y-2"></div>
    </section>
  </main>

  <footer class="max-w-6xl mx-auto text-center text-slate-400 py-6">© <?=date('Y')?> <?=APP_BRAND_HTML?> — Nur mit Einwilligung teilen</footer>

  <script>
  const S = {
    user: <?=json_encode(me() ?: null)?>,
    csrf: '<?=htmlspecialchars(csrf_token())?>'
  };
  const $ = sel => document.querySelector(sel);
  const $$ = sel => Array.from(document.querySelectorAll(sel));
  function html(x){ const d=document.createElement('template'); d.innerHTML=x.trim(); return d.content.firstChild; }
  function toast(msg, ok=true){ const t=html(`<div class="fixed bottom-4 right-4 bg-slate-800/90 border border-white/10 rounded-xl px-3 py-2">${msg}</div>`); document.body.appendChild(t); setTimeout(()=>t.remove(),2200); }

  // Auth box
  function renderAuth(){
    const box = $('#auth-box'); box.innerHTML='';
    if(S.user){
      box.append(html(`<span class="text-sm">Hallo, ${S.user.name||S.user.email}${S.user.email_verified?'':' <span class=\"chip\">unbestätigt</span>'}</span>`));
      const lo=html('<button class="btn btn-ghost">Logout</button>'); lo.onclick=logout; box.append(lo);
      if(S.user.role==='admin' || S.user.role==='moderator'){ $('#nav-pending').classList.remove('hidden'); }
    } else {
      const log=html('<button class="btn btn-primary">Login</button>'); log.onclick=()=>openAuth(true); box.append(log);
      const reg=html('<button class="btn btn-ghost">Registrieren</button>'); reg.onclick=()=>openAuth(false); box.append(reg);
    }
  }
  renderAuth();

  function openAuth(login=true){
    const dlg=html(`<div class="fixed inset-0 bg-black/50 grid place-items-center">
      <div class="card p-4 w-full max-w-md">
        <h3 class="font-bold mb-2">${login?'Login':'Registrieren'}</h3>
        <form id="authForm" class="space-y-3">
          <input type="hidden" name="csrf" value="${S.csrf}">
          <input name="email" type="email" required placeholder="E-Mail" class="w-full bg-slate-800/70 rounded-xl px-3 py-2">
          ${login?'' : '<input name="name" placeholder="Name (optional)" class="w-full bg-slate-800/70 rounded-xl px-3 py-2">'}
          <input name="pass" type="password" required placeholder="Passwort" class="w-full bg-slate-800/70 rounded-xl px-3 py-2">
          <button class="btn btn-primary w-full">${login?'Einloggen':'Konto anlegen'}</button>
        </form>
        <button class="mt-3 text-sm text-slate-400 hover:underline" id="closeAuth">Schließen</button>
      </div></div>`);
    dlg.querySelector('#closeAuth').onclick=()=>dlg.remove();
    dlg.querySelector('#authForm').onsubmit=async (e)=>{
      e.preventDefault();
      const fd=new FormData(e.target);
      const api= login? 'login':'register';
      const res=await fetch(`?api=${api}`,{method:'POST',body:fd}); const j=await res.json();
      if(j.ok){ if(login){ S.user=j.user; renderAuth(); toast('Willkommen!'); dlg.remove(); } else { toast('Bitte E-Mail bestätigen.'); dlg.remove(); } }
      else toast(j.error||'Fehler',false);
    };
    document.body.appendChild(dlg);
  }

  async function logout(){ const fd=new FormData(); fd.append('csrf', S.csrf); const r=await fetch('?api=logout',{method:'POST',body:fd}); const j=await r.json(); if(j.ok){ S.user=null; renderAuth(); } }

  // Navigation
  $('#nav-home').onclick=()=>{ $('#upload').classList.add('hidden'); $('#pending').classList.add('hidden'); };
  $('#nav-upload').onclick=()=>{ $('#upload').classList.remove('hidden'); $('#pending').classList.add('hidden'); };
  $('#nav-pending').onclick=()=>{ $('#upload').classList.add('hidden'); $('#pending').classList.remove('hidden'); loadPending(); };

  // Search / list
  $$('#q,#cat,#sort').forEach(el=> el.addEventListener('input', debounce(loadList, 200)) );
  function debounce(fn,ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a),ms); } }
  async function loadList(){
    const url = `?api=list&q=${encodeURIComponent($('#q').value||'')}&category_id=${$('#cat').value}&sort=${$('#sort').value}`;
    const r=await fetch(url); const j=await r.json();
    const host=$('#list'); host.innerHTML='';
    (j.items||[]).forEach(it=> host.append(renderItemCard(it)) );
  }
  function renderItemCard(it){
    const el=html(`<article class="card p-4 flex flex-col gap-2">
      <div class="flex items-center justify-between gap-2">
        <h3 class="font-bold">${escapeHtml(it.title)}</h3>
        <span class="chip">${escapeHtml(it.cat_name||'—')}</span>
      </div>
      <p class="text-sm text-slate-300 line-clamp-3">${escapeHtml(it.description||'')}</p>
      <div class="flex items-center justify-between text-sm">
        <div class="flex items-center gap-2"><button class="btn btn-ghost" data-up>👍</button><span>${it.score||0}</span><button class="btn btn-ghost" data-down>👎</button></div>
        <div class="flex items-center gap-3">
          <a class="btn btn-primary" href="${it.file_url}" target="_blank" rel="noopener">Download</a>
          <button class="btn btn-ghost" data-open>Details</button>
          <button class="btn btn-ghost" data-report>Melden</button>
        </div>
      </div>
    </article>`);
    el.querySelector('[data-open]').onclick=()=>openItem(it.uid);
    el.querySelector('[data-report]').onclick=()=>openReport(it.uid);
    el.querySelector('[data-up]').onclick=()=>vote(it.uid,1);
    el.querySelector('[data-down]').onclick=()=>vote(it.uid,-1);
    return el;
  }
  function escapeHtml(s){ return (s||'').replace(/[&<>"']/g, c=> ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[c]) ); }

  // Item detail / comments
  async function openItem(uid){
    const r=await fetch(`?api=item&uid=${encodeURIComponent(uid)}`); const j=await r.json(); if(!j.ok) return;
    const it=j.item;
    const dlg=html(`<div class="fixed inset-0 bg-black/50 grid place-items-center"><div class="card p-4 w-full max-w-2xl space-y-3">
      <div class="flex items-center justify-between"><h3 class="font-bold text-lg">${escapeHtml(it.title)}</h3><button class="btn btn-ghost" id="close">Schließen</button></div>
      <a class="btn btn-primary w-full" href="${it.file_url}" target="_blank" rel="noopener">Download</a>
      <p class="text-sm text-slate-300 whitespace-pre-wrap">${escapeHtml(it.description||'')}</p>
      <div class="text-xs text-slate-400">Kategorie: ${escapeHtml(it.cat_name||'—')} · Punkte: ${it.score||0}</div>
      <div class="border-t border-white/10 pt-3">
        <h4 class="font-semibold mb-1">Kommentare</h4>
        <div id="comments" class="space-y-2"></div>
        <form id="commentForm" class="mt-2 flex gap-2">
          <input type="hidden" name="csrf" value="${S.csrf}">
          <input name="body" class="flex-1 bg-slate-800/70 rounded-xl px-3 py-2" placeholder="Kommentar schreiben…">
          <button class="btn btn-primary">Senden</button>
        </form>
      </div>
    </div></div>`);
    dlg.querySelector('#close').onclick=()=>dlg.remove();
    const list=dlg.querySelector('#comments'); list.innerHTML=''; (j.comments||[]).forEach(c=> list.append(html(`<div class="bg-white/5 rounded-xl p-2 text-sm"><div class="text-xs text-slate-400 mb-1">${escapeHtml(c.author||'User')}</div>${escapeHtml(c.body)}</div>`)) );
    dlg.querySelector('#commentForm').onsubmit=async (e)=>{ e.preventDefault(); const fd=new FormData(e.target); const r=await fetch(`?api=comment&uid=${encodeURIComponent(uid)}`,{method:'POST',body:fd}); const j=await r.json(); if(j.ok){ toast('Gesendet'); dlg.remove(); } else toast(j.error||'Fehler',false); };
    document.body.appendChild(dlg);
  }

  // Report
  function openReport(uid){
    const dlg=html(`<div class="fixed inset-0 bg-black/50 grid place-items-center"><div class="card p-4 w-full max-w-md">
      <h3 class="font-bold mb-2">Inhalt melden</h3>
      <form id="repForm" class="space-y-2"><input type="hidden" name="csrf" value="${S.csrf}">
        <textarea name="reason" class="w-full bg-slate-800/70 rounded-xl px-3 py-2" rows="3" placeholder="Warum meldest du das?"></textarea>
        <button class="btn btn-primary w-full">Senden</button>
      </form>
      <button class="mt-3 text-sm text-slate-400 hover:underline" id="close">Schließen</button>
    </div></div>`);
    dlg.querySelector('#close').onclick=()=>dlg.remove();
    dlg.querySelector('#repForm').onsubmit=async (e)=>{ e.preventDefault(); const fd=new FormData(e.target); const r=await fetch(`?api=report&uid=${encodeURIComponent(uid)}`,{method:'POST',body:fd}); const j=await r.json(); if(j.ok){ toast('Danke für die Meldung'); dlg.remove(); } else toast(j.error||'Fehler',false); };
    document.body.appendChild(dlg);
  }

  // Vote
  async function vote(uid,val){ const fd=new FormData(); fd.append('csrf',S.csrf); fd.append('value',val); const r=await fetch(`?api=vote&uid=${encodeURIComponent(uid)}`,{method:'POST',body:fd}); const j=await r.json(); if(j.ok){ loadList(); } else toast(j.error||'Fehler',false); }

  // Upload
  $('#uploadForm').onsubmit=async (e)=>{ e.preventDefault(); const fd=new FormData(e.target); const r=await fetch('?api=upload',{method:'POST',body:fd}); const j=await r.json(); if(j.ok){ toast('Upload eingereicht'); e.target.reset(); } else toast(j.error||'Fehler',false); };

  // Moderation
  async function loadPending(){ const r=await fetch('?api=mod_list'); const j=await r.json(); const host=$('#pendingList'); host.innerHTML=''; (j.items||[]).forEach(it=> host.append(renderPending(it)) ); }
  function renderPending(it){ const el=html(`<div class="bg-white/5 rounded-xl p-3 flex flex-col gap-2">
    <div class="flex items-center justify-between"><div class="font-semibold">${escapeHtml(it.title)}</div><span class="chip">${escapeHtml(it.cat_name||'')}</span></div>
    <div class="text-xs text-slate-400">Von: ${escapeHtml(it.submitter||'—')} · ${new Date(it.created_at.replace(' ','T')).toLocaleString()}</div>
    <div class="text-sm">${escapeHtml(it.description||'')}</div>
    <div class="flex items-center gap-2"><a class="btn btn-primary" href="${it.file_url}" target="_blank">Datei prüfen</a><button class="btn btn-ghost" data-app>Freigeben</button><button class="btn btn-ghost" data-rej>Ablehnen</button></div>
  </div>`);
    el.querySelector('[data-app]').onclick=()=>modAction(it.uid,'approve');
    el.querySelector('[data-rej]').onclick=()=>modAction(it.uid,'reject');
    return el;
  }
  async function modAction(uid,action){ const fd=new FormData(); fd.append('csrf',S.csrf); const r=await fetch(`?api=mod_action&uid=${encodeURIComponent(uid)}&action=${action}`,{method:'POST',body:fd}); const j=await r.json(); if(j.ok){ toast('OK'); loadPending(); } else toast(j.error||'Fehler',false); }

  // initial
  loadList();
  </script>
</body>
</html>
Vorheriges Tutorial
Nächstes Tutorial

Hier etwas für dich dabei?