Dienstag, 20 Januar 2026

Diese Woche am beliebtesten

Vertiefendes Material

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>
Dreamcodes Redaktion
Dreamcodes Redaktion
Qualität als Standard. Verantwortung als Prinzip. Jede Ressource auf Dreamcodes basiert auf geprüften Best Practices und fundierter Praxiserfahrung. Unser Anspruch ist ein belastbares Fundament statt experimenteller Lösungen. Die Integration und Absicherung der Inhalte liegt in Ihrem Ermessen. Wir liefern die fachliche Basis, die Verantwortung für den produktiven Einsatz verbleibt bei Ihnen.
Vorheriges Tutorial
Nächstes Tutorial

Vielleicht einen Blick WERT?