Workspace: Dateiablage mit Freigabe-Ablauf (Phase 1 vollstaendig)
/workspace/dateien.html -- Ablegen per Ziehen oder Auswaehlen, Entwurf -> Review -> Freigabe, Filter je Zustand. OHNE NEUE ABHAENGIGKEIT. Uploads laufen ueblicherweise ueber multer. Hier schickt der Browser die Datei roh im Rumpf, der Name steht im Kopf (URI-kodiert, damit Umlaute heil ankommen). express.raw() bringt den passenden Empfaenger schon mit -- kein Zerlegen von multipart/form-data von Hand, was gerade hier heikel waere. Die drei Punkte, an denen Dateiablagen typischerweise scheitern: 1. Die Dateien liegen AUSSERHALB des Repos (workspace-daten/dateien/). Lagen sie darin, wuerde express.static sie ungeprueft ans Netz geben. 2. Auf der Platte traegt jede Datei einen erzeugten Zufallsnamen, der Originalname steht nur in der Datenbank. Geprueft mit dem Dateinamen "../../etc/passwd": gespeichert wurde "passwd", auf der Platte ein Zufallsname IM Ordner -- nichts ist ausgebrochen. 3. Ausgeliefert wird immer als Download mit neutralem Typ, dazu nosniff und CSP sandbox. Geprueft mit einer hochgeladenen boese.html: kommt als application/octet-stream zurueck, kann also keinen Code im Namen der Domain ausfuehren. Freigabe nach Konzept: Der Zustand "freigegeben" ist eine Abnahme und bleibt dem Management vorbehalten. Geprueft -- Luna kann Entwurf -> Review, aber nicht freigeben (403); eine freigegebene Datei kann sie weder aendern noch loeschen. Sichtbarkeit wie ueberall: Chef 2 Dateien, Luna 1, Mika 0. Zugriff auf eine fremde Datei: 404, nicht 403. Ein Fehler beim Testen gefunden: Bei zu grosser Datei brach express.raw ab, BEVOR der eigene Code lief -- der Fehler landete im allgemeinen Behandler als HTTP 500. Fuer die Nutzerin sah das aus wie ein kaputter Server statt wie "Datei zu gross". Jetzt faengt ein eigener Fehlerbehandler am Ende des Routers das ab und antwortet mit 413 und einer verstaendlichen Meldung. Damit ist Phase 1 aus dem Konzept vollstaendig.
This commit is contained in:
@@ -18,6 +18,7 @@ import { aufgabenRouter } from "./workspace-aufgaben.js";
|
||||
import { personenRouter } from "./workspace-personen.js";
|
||||
import { profilRouter } from "./workspace-profil.js";
|
||||
import { kalenderRouter } from "./workspace-kalender.js";
|
||||
import { dateienRouter } from "./workspace-dateien.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SITE_DIR = join(__dirname, "..");
|
||||
@@ -76,6 +77,7 @@ app.use(aufgabenRouter);
|
||||
app.use(personenRouter);
|
||||
app.use(profilRouter);
|
||||
app.use(kalenderRouter);
|
||||
app.use(dateienRouter);
|
||||
|
||||
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
|
||||
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
|
||||
|
||||
Binary file not shown.
@@ -43,6 +43,11 @@ const require = createRequire(pathToFileURL(__dirname + "/"));
|
||||
const DB_PFAD = process.env.WORKSPACE_DB
|
||||
|| join(__dirname, "..", "..", "workspace-daten", "workspace.db");
|
||||
|
||||
/* Ordner fuer hochgeladene Dateien -- neben der Datenbank, also ebenfalls
|
||||
ausserhalb des Repos. Waeren sie im Repo, wuerde express.static sie
|
||||
ungeschuetzt ausliefern. */
|
||||
export const DATEN_ORDNER = dirname(DB_PFAD);
|
||||
|
||||
const COOKIE = "dfw_sitzung";
|
||||
const SITZUNG_STUNDEN = 12;
|
||||
const VERSUCHE_MAX = 8; // pro IP
|
||||
@@ -171,6 +176,27 @@ export function db() {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_termine_beginn ON termine (beginn);
|
||||
|
||||
/* Dateiablage. Auf der Platte traegt jede Datei einen erzeugten
|
||||
Zufallsnamen (name_datei), der Originalname steht nur hier.
|
||||
Dadurch kann ein Dateiname weder Pfade verlassen noch etwas
|
||||
ueberschreiben. Der Freigabe-Ablauf aus dem Konzept steckt in
|
||||
status: entwurf -> review -> freigegeben. */
|
||||
CREATE TABLE IF NOT EXISTS dateien (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name_original TEXT NOT NULL,
|
||||
name_datei TEXT NOT NULL UNIQUE,
|
||||
groesse INTEGER NOT NULL,
|
||||
typ TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'entwurf'
|
||||
CHECK (status IN ('entwurf','review','freigegeben')),
|
||||
notiz TEXT,
|
||||
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
hochgeladen_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
erstellt TEXT NOT NULL,
|
||||
geaendert TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dateien_creator ON dateien (creator_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS versuche (
|
||||
ip TEXT NOT NULL,
|
||||
zeitpunkt TEXT NOT NULL
|
||||
@@ -305,6 +331,7 @@ const GESCHUETZT = {
|
||||
"/workspace/personen.html": ["admin"],
|
||||
"/workspace/profil.html": ["admin", "creator"],
|
||||
"/workspace/kalender.html": null,
|
||||
"/workspace/dateien.html": null,
|
||||
};
|
||||
|
||||
workspaceRouter.use((req, res, next) => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/* ===================================================================
|
||||
Dateiablage mit Freigabe-Ablauf.
|
||||
=================================================================== */
|
||||
|
||||
.ablage {
|
||||
display: grid; place-items: center; gap: 6px;
|
||||
padding: 34px 20px; margin-bottom: 16px;
|
||||
border: 1px dashed var(--rand);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, .02);
|
||||
cursor: pointer; text-align: center;
|
||||
transition: border-color var(--tempo), background var(--tempo);
|
||||
}
|
||||
.ablage:hover, .ablage:focus-visible {
|
||||
border-color: rgba(63, 189, 245, .5);
|
||||
background: rgba(63, 189, 245, .05);
|
||||
outline: none;
|
||||
}
|
||||
/* Während eine Datei darüber schwebt, deutlich sichtbar -- sonst weiß
|
||||
niemand, ob das Loslassen an dieser Stelle etwas bewirkt. */
|
||||
.ablage[data-bereit="ja"] {
|
||||
border-color: var(--akzent);
|
||||
border-style: solid;
|
||||
background: rgba(63, 189, 245, .10);
|
||||
}
|
||||
.ablage__zeichen {
|
||||
width: 30px; height: 30px;
|
||||
fill: none; stroke: var(--text-still);
|
||||
stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round;
|
||||
}
|
||||
.ablage:hover .ablage__zeichen, .ablage[data-bereit="ja"] .ablage__zeichen { stroke: var(--akzent); }
|
||||
.ablage__text { margin: 0; font-size: .92rem; color: var(--text-leise); }
|
||||
.ablage__text strong { color: var(--akzent); }
|
||||
.ablage__klein { margin: 0; font-size: .78rem; color: var(--text-still); }
|
||||
|
||||
.hochlade-zeile { margin-bottom: 18px; }
|
||||
.hochlade-zeile select {
|
||||
min-width: 240px; padding: 9px 12px;
|
||||
background: rgba(255, 255, 255, .045);
|
||||
border: 1px solid var(--rand); border-radius: var(--radius-klein);
|
||||
color: var(--text); font: inherit;
|
||||
}
|
||||
|
||||
.fortschritt {
|
||||
margin: 0 0 14px; padding: 10px 14px;
|
||||
background: rgba(63, 189, 245, .08);
|
||||
border: 1px solid rgba(63, 189, 245, .3);
|
||||
border-radius: var(--radius-klein);
|
||||
font-size: .86rem; color: var(--text-leise);
|
||||
}
|
||||
|
||||
.filter { display: flex; gap: 8px; margin: 0 0 20px; flex-wrap: wrap; }
|
||||
.filter .schritt[aria-pressed="true"] {
|
||||
background: rgba(63, 189, 245, .14);
|
||||
border-color: rgba(63, 189, 245, .55);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---------- Liste ------------------------------------------------------- */
|
||||
|
||||
.datei {
|
||||
display: grid; gap: 4px 16px; align-items: center;
|
||||
grid-template-columns: 34px 1fr auto;
|
||||
padding: 13px 16px; margin-bottom: 9px;
|
||||
background: rgba(255, 255, 255, .03);
|
||||
border: 1px solid var(--rand);
|
||||
border-left: 3px solid var(--rand);
|
||||
border-radius: var(--radius-klein);
|
||||
}
|
||||
.datei[data-status="entwurf"] { border-left-color: #6f89a8; }
|
||||
.datei[data-status="review"] { border-left-color: #e0a33c; }
|
||||
.datei[data-status="freigegeben"] { border-left-color: #4dab77;
|
||||
background: rgba(77, 171, 119, .05); }
|
||||
|
||||
.datei__zeichen {
|
||||
grid-row: span 2; width: 26px; height: 26px;
|
||||
fill: none; stroke: var(--text-still);
|
||||
stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round;
|
||||
}
|
||||
.datei__name { font-weight: 600; font-size: .93rem; word-break: break-word; }
|
||||
.datei__zeile {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
font-size: .78rem; color: var(--text-still);
|
||||
}
|
||||
.datei__knoepfe { grid-row: span 2; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.marke-status {
|
||||
padding: 1px 9px; border-radius: 999px;
|
||||
font-size: .68rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
|
||||
}
|
||||
.marke-status[data-status="entwurf"] { background: rgba(111, 137, 168, .18); color: #a8bdd4; }
|
||||
.marke-status[data-status="review"] { background: rgba(224, 163, 60, .16); color: #e6b45c; }
|
||||
.marke-status[data-status="freigegeben"] { background: rgba(77, 171, 119, .18); color: #79d1a2; }
|
||||
|
||||
.leer-hinweis {
|
||||
padding: 26px 20px; text-align: center;
|
||||
color: var(--text-still); font-size: .9rem;
|
||||
border: 1px dashed var(--rand); border-radius: var(--radius);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.datei { grid-template-columns: 34px 1fr; }
|
||||
.datei__knoepfe { grid-row: auto; grid-column: 1 / -1; margin-top: 9px; }
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/* ===================================================================
|
||||
Dateiablage.
|
||||
|
||||
Hochgeladen wird der rohe Dateiinhalt, der Name steht im Kopf --
|
||||
deshalb braucht der Server kein multipart/form-data zu zerlegen.
|
||||
Wer was sehen und freigeben darf, entscheidet ausschliesslich er.
|
||||
=================================================================== */
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const STATUSNAME = { entwurf: 'Entwurf', review: 'Review', freigegeben: 'Freigegeben' };
|
||||
const WEITER = { entwurf: 'review', review: 'freigegeben' };
|
||||
const ZURUECK = { review: 'entwurf', freigegeben: 'review' };
|
||||
|
||||
let ich = null;
|
||||
let filter = '';
|
||||
let maxBytes = 25 * 1024 * 1024;
|
||||
|
||||
const el = (tag, klasse, text) => {
|
||||
const k = document.createElement(tag);
|
||||
if (klasse) k.className = klasse;
|
||||
if (text !== undefined) k.textContent = text;
|
||||
return k;
|
||||
};
|
||||
const melde = (t) => { $('fehler').textContent = t || ''; };
|
||||
|
||||
async function hole(pfad, optionen = {}) {
|
||||
const a = await fetch(pfad, { credentials: 'same-origin', ...optionen });
|
||||
if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
|
||||
return a;
|
||||
}
|
||||
|
||||
function groesse(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB';
|
||||
return (bytes / 1024 / 1024).toFixed(1).replace('.', ',') + ' MB';
|
||||
}
|
||||
const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : '—');
|
||||
|
||||
/* ---------- Hochladen -------------------------------------------------- */
|
||||
|
||||
async function schicke(datei) {
|
||||
melde('');
|
||||
if (datei.size > maxBytes) {
|
||||
melde(`„${datei.name}" ist ${groesse(datei.size)} groß – erlaubt sind ${groesse(maxBytes)}.`);
|
||||
return;
|
||||
}
|
||||
$('fortschritt').hidden = false;
|
||||
$('fortschritt-text').textContent = `„${datei.name}" wird hochgeladen …`;
|
||||
try {
|
||||
const kopf = {
|
||||
'Content-Type': datei.type || 'application/octet-stream',
|
||||
/* Kodiert, damit Umlaute und Leerzeichen im Kopf heil ankommen. */
|
||||
'X-Dateiname': encodeURIComponent(datei.name),
|
||||
};
|
||||
if (ich.rolle === 'admin' && $('f-creator').value) kopf['X-Creator'] = $('f-creator').value;
|
||||
|
||||
const a = await hole('/workspace/api/dateien', { method: 'POST', headers: kopf, body: datei });
|
||||
if (!a.ok) {
|
||||
const d = await a.json().catch(() => ({}));
|
||||
melde(d.fehler || 'Hochladen hat nicht geklappt.');
|
||||
return;
|
||||
}
|
||||
await laden();
|
||||
} catch { /* umgeleitet */ } finally {
|
||||
$('fortschritt').hidden = true;
|
||||
$('feld-datei').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
const ablage = $('ablage');
|
||||
ablage.addEventListener('click', () => $('feld-datei').click());
|
||||
ablage.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('feld-datei').click(); }
|
||||
});
|
||||
$('feld-datei').addEventListener('change', () => {
|
||||
for (const d of $('feld-datei').files) schicke(d);
|
||||
});
|
||||
|
||||
for (const art of ['dragenter', 'dragover']) {
|
||||
ablage.addEventListener(art, (e) => { e.preventDefault(); ablage.dataset.bereit = 'ja'; });
|
||||
}
|
||||
for (const art of ['dragleave', 'drop']) {
|
||||
ablage.addEventListener(art, () => { delete ablage.dataset.bereit; });
|
||||
}
|
||||
ablage.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
for (const d of e.dataTransfer.files) schicke(d);
|
||||
});
|
||||
/* Ohne diese beiden öffnet der Browser eine daneben fallengelassene
|
||||
Datei einfach als Seite -- die Arbeit wäre dann weg. */
|
||||
addEventListener('dragover', (e) => e.preventDefault());
|
||||
addEventListener('drop', (e) => e.preventDefault());
|
||||
|
||||
/* ---------- Liste ------------------------------------------------------ */
|
||||
|
||||
function tuKnopf(text, tu) {
|
||||
const b = el('button', 'schritt', text);
|
||||
b.type = 'button';
|
||||
b.addEventListener('click', async () => {
|
||||
b.disabled = true; melde('');
|
||||
try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; }
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
async function statusSetzen(d, neu) {
|
||||
const a = await hole(`/workspace/api/dateien/${d.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: neu }),
|
||||
});
|
||||
if (!a.ok) {
|
||||
melde((await a.json().catch(() => ({}))).fehler || 'Ändern hat nicht geklappt.');
|
||||
return;
|
||||
}
|
||||
await laden();
|
||||
}
|
||||
|
||||
function zeile(d) {
|
||||
const k = el('div', 'datei');
|
||||
k.dataset.status = d.status;
|
||||
|
||||
const zeichen = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
zeichen.setAttribute('viewBox', '0 0 24 24');
|
||||
zeichen.setAttribute('class', 'datei__zeichen');
|
||||
zeichen.setAttribute('aria-hidden', 'true');
|
||||
const pfad = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
pfad.setAttribute('d', 'M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5L13.5 3Zm0 0V8a.5.5 0 0 0 .5.5h5');
|
||||
zeichen.append(pfad);
|
||||
k.append(zeichen);
|
||||
|
||||
k.append(el('div', 'datei__name', d.name_original));
|
||||
|
||||
const knoepfe = el('div', 'datei__knoepfe');
|
||||
const marke = el('span', 'marke-status', STATUSNAME[d.status] || d.status);
|
||||
marke.dataset.status = d.status;
|
||||
knoepfe.append(marke);
|
||||
|
||||
const holen = el('a', 'schritt', 'öffnen');
|
||||
holen.href = `/workspace/api/dateien/${d.id}/inhalt`;
|
||||
knoepfe.append(holen);
|
||||
|
||||
if (WEITER[d.status]) {
|
||||
const darf = d.status !== 'review' || ich.rolle === 'admin';
|
||||
if (darf) knoepfe.append(tuKnopf(
|
||||
d.status === 'entwurf' ? 'zum Review ▶' : 'freigeben ▶',
|
||||
() => statusSetzen(d, WEITER[d.status])));
|
||||
}
|
||||
if (ZURUECK[d.status] && (d.status !== 'freigegeben' || ich.rolle === 'admin')) {
|
||||
knoepfe.append(tuKnopf('◀ zurück', () => statusSetzen(d, ZURUECK[d.status])));
|
||||
}
|
||||
if (ich.rolle === 'admin' || (d.hochgeladen_von === ich.id && d.status !== 'freigegeben')) {
|
||||
knoepfe.append(tuKnopf('löschen', async () => {
|
||||
if (!confirm(`„${d.name_original}" wirklich löschen?`)) return;
|
||||
const a = await hole(`/workspace/api/dateien/${d.id}`, { method: 'DELETE' });
|
||||
if (!a.ok) { melde('Löschen hat nicht geklappt.'); return; }
|
||||
await laden();
|
||||
}));
|
||||
}
|
||||
k.append(knoepfe);
|
||||
|
||||
const zeile2 = el('div', 'datei__zeile');
|
||||
zeile2.append(el('span', null, groesse(d.groesse)));
|
||||
zeile2.append(el('span', null, '· ' + datum(d.erstellt)));
|
||||
if (d.hochgeladen_name) zeile2.append(el('span', null, '· von ' + d.hochgeladen_name));
|
||||
if (ich.rolle === 'admin' && d.creator_name) {
|
||||
zeile2.append(el('span', null, '· Bereich ' + d.creator_name));
|
||||
}
|
||||
if (d.notiz) zeile2.append(el('span', null, '· ' + d.notiz));
|
||||
k.append(zeile2);
|
||||
return k;
|
||||
}
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const a = await hole('/workspace/api/dateien');
|
||||
if (!a.ok) { melde('Dateien konnten nicht geladen werden.'); return; }
|
||||
const daten = await a.json();
|
||||
maxBytes = daten.max_bytes || maxBytes;
|
||||
$('ablage-grenze').textContent = 'bis ' + groesse(maxBytes);
|
||||
|
||||
const ziel = $('liste');
|
||||
ziel.textContent = '';
|
||||
ziel.setAttribute('aria-busy', 'false');
|
||||
|
||||
const sichtbar = daten.dateien.filter((d) => !filter || d.status === filter);
|
||||
if (!sichtbar.length) {
|
||||
ziel.append(el('p', 'leer-hinweis', daten.dateien.length
|
||||
? 'In diesem Zustand liegt gerade nichts.'
|
||||
: 'Noch keine Datei abgelegt.'));
|
||||
return;
|
||||
}
|
||||
for (const d of sichtbar) ziel.append(zeile(d));
|
||||
} catch { /* umgeleitet */ }
|
||||
}
|
||||
|
||||
for (const b of $('filter').querySelectorAll('.schritt')) {
|
||||
b.addEventListener('click', async () => {
|
||||
filter = b.dataset.status;
|
||||
for (const x of $('filter').querySelectorAll('.schritt')) {
|
||||
x.setAttribute('aria-pressed', String(x === b));
|
||||
}
|
||||
await laden();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Start ------------------------------------------------------- */
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const a = await hole('/workspace/api/ich');
|
||||
if (!a.ok) { location.assign('/workspace/'); return; }
|
||||
ich = await a.json();
|
||||
} catch { return; }
|
||||
$('wer').textContent = ich.name + ' · ' + ich.rolle;
|
||||
|
||||
if (ich.rolle === 'admin') {
|
||||
$('zuordnung').hidden = false;
|
||||
try {
|
||||
const { personen } = await (await hole('/workspace/api/personen')).json();
|
||||
for (const p of personen.filter((p) => p.rolle === 'creator')) {
|
||||
const o = document.createElement('option');
|
||||
o.value = String(p.id);
|
||||
o.textContent = p.name;
|
||||
$('f-creator').append(o);
|
||||
}
|
||||
} catch { /* Zuordnung bleibt leer */ }
|
||||
}
|
||||
await laden();
|
||||
})();
|
||||
})();
|
||||
@@ -22,7 +22,7 @@
|
||||
{ name: 'Mein Profil', phase: 1, rollen: ['creator'], ziel: 'profil.html' },
|
||||
{ name: 'Aufgaben', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'aufgaben.html' },
|
||||
{ name: 'Kalender', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'kalender.html' },
|
||||
{ name: 'Dateien', phase: 1, rollen: ['admin', 'creator'] },
|
||||
{ name: 'Dateien', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'dateien.html' },
|
||||
{ name: 'LIVE-Analyse', phase: 2, rollen: ['admin', 'creator'] },
|
||||
{ name: 'Content-Planung', phase: 2, rollen: ['admin', 'creator'] },
|
||||
{ name: 'Technik', phase: 2, rollen: ['admin', 'creator'] },
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Dateien · Creator Workspace</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css" />
|
||||
<link rel="stylesheet" href="assets/css/start.css" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
|
||||
<header class="kopfleiste">
|
||||
<button type="button" class="zurueck-knopf" id="zurueck" hidden>
|
||||
<svg class="zurueck-knopf__pfeil" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M14.5 5.5 8 12l6.5 6.5" />
|
||||
</svg>
|
||||
<span class="zurueck-knopf__text">Zurück</span>
|
||||
</button>
|
||||
<p class="marke"><a class="zurueck" href="start.html">Creator Workspace</a> · Dateien</p>
|
||||
<div class="kopfleiste__rechts">
|
||||
<span class="wer" id="wer">…</span>
|
||||
<button type="button" class="abmelden" id="abmelden">Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="inhalt inhalt--breit">
|
||||
|
||||
<section class="kopf-zeile">
|
||||
<div>
|
||||
<p class="marke">Ablage</p>
|
||||
<h1 class="titel">Dateien & Freigaben</h1>
|
||||
<p class="unterzeile">Entwurf → Review → Freigabe. Die aktuelle Version ist immer klar erkennbar.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Ablegefeld. Klick öffnet die Dateiauswahl, Ziehen funktioniert
|
||||
ebenso -- beides landet im selben Ablauf. -->
|
||||
<div class="ablage" id="ablage" tabindex="0" role="button"
|
||||
aria-label="Datei auswählen oder hierher ziehen">
|
||||
<svg class="ablage__zeichen" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5" />
|
||||
<path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15" />
|
||||
</svg>
|
||||
<p class="ablage__text">Datei hierher ziehen oder <strong>auswählen</strong></p>
|
||||
<p class="ablage__klein" id="ablage-grenze">bis 25 MB</p>
|
||||
<input type="file" id="feld-datei" hidden />
|
||||
</div>
|
||||
|
||||
<div class="hochlade-zeile" id="zuordnung" hidden>
|
||||
<label class="feld-schild" for="f-creator">Ablegen im Bereich von</label>
|
||||
<select id="f-creator"><option value="">— kein Bereich —</option></select>
|
||||
</div>
|
||||
|
||||
<p class="fortschritt" id="fortschritt" hidden><span id="fortschritt-text"></span></p>
|
||||
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
|
||||
|
||||
<div class="filter" id="filter">
|
||||
<button type="button" class="schritt" data-status="" aria-pressed="true">Alle</button>
|
||||
<button type="button" class="schritt" data-status="entwurf" aria-pressed="false">Entwurf</button>
|
||||
<button type="button" class="schritt" data-status="review" aria-pressed="false">Review</button>
|
||||
<button type="button" class="schritt" data-status="freigegeben" aria-pressed="false">Freigegeben</button>
|
||||
</div>
|
||||
|
||||
<div id="liste" aria-busy="true"><p class="leise">Dateien werden geladen …</p></div>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/kopf.js" defer></script>
|
||||
<script src="assets/js/dateien.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user