Ordner durchklicken, statt den Pfad zu wissen
Beim Anlegen einer Mediathek verlangte das Feld den Pfad im Container — nicht den auf dem NAS. Diese Unterscheidung kennt nur, wer die Compose-Datei selbst geschrieben hat, und ein Tippfehler darin ergibt eine leere Mediathek ohne erkennbaren Grund. Neben dem Feld steht jetzt ein Knopf, der die Ordner des Servers im selben Dialog aufklappt: Pfadzeile in anklickbaren Stücken, Unterordner darunter, unten der Knopf mit dem gewählten Pfad darauf. Im selben Dialog und nicht in einem zweiten darüber: zwei gestapelte Dialoge sind beim Schliessen immer eine Frage zu viel. Das Feld bleibt tippbar. Wer den Pfad kennt, ist damit schneller, und bei einem tiefen Baum ist es die einzige geduldschonende Möglichkeit. An der Wurzel bleiben /proc, /sys, /usr und Verwandte aussen vor. Das ist keine Absperrung — wer den Pfad kennt, tippt ihn weiterhin ein —, sondern eine Liste aus zwanzig Einträgen, von denen achtzehn nie eine Mediathek enthalten, ist unbrauchbar. Ein Ordner ohne Leserecht wird gemeldet, nicht geworfen. Ohne das flöge eine UnauthorizedAccessException bis in die Antwort und würde zur 500, während die ehrliche Auskunft lautet: den darfst du nicht lesen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
64fb61f2dc
commit
b25c7a4712
@@ -0,0 +1,151 @@
|
|||||||
|
using Flaro.Api;
|
||||||
|
|
||||||
|
namespace Flaro.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>Das Durchklicken durch das Dateisystem.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Alles unter <see cref="Path.GetTempPath"/>, nicht unter festen Pfaden.</b>
|
||||||
|
/// Diese Tests laufen auf dem Arbeitsrechner, wo es weder <c>/media</c> noch
|
||||||
|
/// <c>/volume1</c> gibt — ein Test, der das voraussetzt, prüft nur, auf
|
||||||
|
/// welchem Rechner er gerade läuft.
|
||||||
|
/// </remarks>
|
||||||
|
public class OrdnerbaumTests
|
||||||
|
{
|
||||||
|
private string _wurzel = "";
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void Anlegen() =>
|
||||||
|
_wurzel = Directory.CreateDirectory(
|
||||||
|
Path.Combine(Path.GetTempPath(), "flaro-baum-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public void Aufraeumen()
|
||||||
|
{
|
||||||
|
// Rechte zurücksetzen, sonst bleibt das gesperrte Verzeichnis liegen.
|
||||||
|
if (!OperatingSystem.IsWindows() && Directory.Exists(_wurzel))
|
||||||
|
{
|
||||||
|
foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories))
|
||||||
|
File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory.Exists(_wurzel)) Directory.Delete(_wurzel, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ListetUnterordnerAlphabetisch()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.Combine(_wurzel, "Serien"));
|
||||||
|
Directory.CreateDirectory(Path.Combine(_wurzel, "filme"));
|
||||||
|
Directory.CreateDirectory(Path.Combine(_wurzel, "Dokus"));
|
||||||
|
|
||||||
|
var baum = Ordnerbaum.Lesen(_wurzel);
|
||||||
|
|
||||||
|
Assert.That(baum, Is.Not.Null);
|
||||||
|
// Ohne Rücksicht auf Gross- und Kleinschreibung — sonst stünde
|
||||||
|
// „filme" hinter „Serien", was in einer Liste niemand sucht.
|
||||||
|
Assert.That(baum!.Entries.Select(e => e.Name), Is.EqualTo(new[] { "Dokus", "filme", "Serien" }));
|
||||||
|
Assert.That(baum.Note, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dateien gehören nicht in eine Ordnerauswahl.</summary>
|
||||||
|
[Test]
|
||||||
|
public void ZeigtNurOrdnerKeineDateien()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.Combine(_wurzel, "Filme"));
|
||||||
|
File.WriteAllText(Path.Combine(_wurzel, "Dune.mkv"), "");
|
||||||
|
|
||||||
|
var baum = Ordnerbaum.Lesen(_wurzel);
|
||||||
|
|
||||||
|
Assert.That(baum!.Entries.Select(e => e.Name), Is.EqualTo(new[] { "Filme" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void KenntDenUebergeordnetenOrdner()
|
||||||
|
{
|
||||||
|
var unten = Directory.CreateDirectory(Path.Combine(_wurzel, "Filme", "Tief")).FullName;
|
||||||
|
|
||||||
|
var baum = Ordnerbaum.Lesen(unten);
|
||||||
|
|
||||||
|
Assert.That(baum!.Parent, Is.EqualTo(Path.Combine(_wurzel, "Filme")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>„/media/../etc" wird zu „/etc" — der angezeigte Pfad ist der, den man tippen würde.</summary>
|
||||||
|
[Test]
|
||||||
|
public void LoestPunktPunktAuf()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.Combine(_wurzel, "Filme"));
|
||||||
|
var verschlungen = Path.Combine(_wurzel, "Filme", "..", "Filme");
|
||||||
|
|
||||||
|
var baum = Ordnerbaum.Lesen(verschlungen);
|
||||||
|
|
||||||
|
Assert.That(baum!.Path, Is.EqualTo(Path.Combine(_wurzel, "Filme")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>null</c> heisst „diesen Pfad gibt es nicht" — nicht „leer".</summary>
|
||||||
|
[Test]
|
||||||
|
public void GibtNullFuerEinenPfadDenEsNichtGibt() =>
|
||||||
|
Assert.That(Ordnerbaum.Lesen(Path.Combine(_wurzel, "gibtesnicht")), Is.Null);
|
||||||
|
|
||||||
|
/// <summary>Eine Datei ist kein Ordner, auch wenn es sie gibt.</summary>
|
||||||
|
[Test]
|
||||||
|
public void GibtNullFuerEineDatei()
|
||||||
|
{
|
||||||
|
var datei = Path.Combine(_wurzel, "Dune.mkv");
|
||||||
|
File.WriteAllText(datei, "");
|
||||||
|
|
||||||
|
Assert.That(Ordnerbaum.Lesen(datei), Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void SagtWennEsNichtsDarunterGibt()
|
||||||
|
{
|
||||||
|
var baum = Ordnerbaum.Lesen(_wurzel);
|
||||||
|
|
||||||
|
Assert.That(baum!.Entries, Is.Empty);
|
||||||
|
Assert.That(baum.Note, Is.EqualTo("Keine Unterordner."));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein Ordner ohne Leserecht ist eine Auskunft, kein Serverfehler.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Ohne diesen Fall flöge eine <see cref="UnauthorizedAccessException"/> bis
|
||||||
|
/// in die Antwort und würde zu einer 500 — während die ehrliche Antwort
|
||||||
|
/// lautet: den darfst du nicht lesen.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void MeldetFehlendesLeserechtStattZuWerfen()
|
||||||
|
{
|
||||||
|
// Mit `return` statt nur `Assert.Ignore`: der Plattform-Analysator
|
||||||
|
// kennt `Assert.Ignore` nicht als Abbruch und hielte den Rest sonst
|
||||||
|
// unter Windows für erreichbar — CA1416.
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
Assert.Ignore("Unix-Rechte gibt es dort nicht.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var gesperrt = Directory.CreateDirectory(Path.Combine(_wurzel, "gesperrt")).FullName;
|
||||||
|
Directory.CreateDirectory(Path.Combine(gesperrt, "darin"));
|
||||||
|
File.SetUnixFileMode(gesperrt, UnixFileMode.None);
|
||||||
|
|
||||||
|
var baum = Ordnerbaum.Lesen(gesperrt);
|
||||||
|
|
||||||
|
Assert.That(baum, Is.Not.Null);
|
||||||
|
Assert.That(baum!.Entries, Is.Empty);
|
||||||
|
Assert.That(baum.Note, Is.EqualTo("Kein Leserecht für diesen Ordner."));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ohne Angabe fängt die Auswahl irgendwo Sinnvollem an — und existiert.</summary>
|
||||||
|
[Test]
|
||||||
|
public void FaengtBeiEinemVorhandenenOrdnerAn()
|
||||||
|
{
|
||||||
|
var anfang = Ordnerbaum.Anfang();
|
||||||
|
|
||||||
|
Assert.That(anfang, Is.Not.Empty);
|
||||||
|
Assert.That(Directory.Exists(anfang), $"Der Anfang „{anfang}\" müsste es geben.");
|
||||||
|
Assert.That(Ordnerbaum.Lesen(null), Is.Not.Null);
|
||||||
|
Assert.That(Ordnerbaum.Lesen(" "), Is.Not.Null, "Leerzeichen zählen wie keine Angabe.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -418,6 +418,20 @@ export const laufendeWiedergaben = () =>
|
|||||||
|
|
||||||
export const serverzustand = () => anfrage<Serverzustand>('/system/status');
|
export const serverzustand = () => anfrage<Serverzustand>('/system/status');
|
||||||
|
|
||||||
|
/** Ein Ordner im Dateisystem des Servers, zum Durchklicken. */
|
||||||
|
export type Ordnerstand = {
|
||||||
|
path: string;
|
||||||
|
/** `null` an der Wurzel — dann gibt es kein „eine Ebene hoch". */
|
||||||
|
parent: string | null;
|
||||||
|
entries: { name: string; path: string }[];
|
||||||
|
/** Warum die Liste leer ist — oder `null`, wenn sie es nicht ist. */
|
||||||
|
note: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Listet Unterordner. Ohne Pfad fängt der Server bei etwas Sinnvollem an. */
|
||||||
|
export const ordner = (pfad?: string) =>
|
||||||
|
anfrage<Ordnerstand>('/system/folders' + (pfad ? `?path=${encodeURIComponent(pfad)}` : ''));
|
||||||
|
|
||||||
export const servernameSetzen = (name: string) =>
|
export const servernameSetzen = (name: string) =>
|
||||||
anfrage<void>('/system/name', { method: 'PUT', body: JSON.stringify({ name }) });
|
anfrage<void>('/system/name', { method: 'PUT', body: JSON.stringify({ name }) });
|
||||||
|
|
||||||
|
|||||||
+165
-2
@@ -1,5 +1,6 @@
|
|||||||
import { icons } from './icons';
|
import { icons } from './icons';
|
||||||
import { knopf } from './util';
|
import { knopf } from './util';
|
||||||
|
import { ordner, type Ordnerstand } from './api';
|
||||||
|
|
||||||
/* Dialoge über dem Inhalt statt Blöcken darin.
|
/* Dialoge über dem Inhalt statt Blöcken darin.
|
||||||
*
|
*
|
||||||
@@ -9,7 +10,7 @@ import { knopf } from './util';
|
|||||||
* Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst
|
* Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst
|
||||||
* baut, ist eine Zeile, die es schlechter kann. */
|
* baut, ist eine Zeile, die es schlechter kann. */
|
||||||
|
|
||||||
export type Feldart = 'text' | 'password' | 'number';
|
export type Feldart = 'text' | 'password' | 'number' | 'ordner';
|
||||||
|
|
||||||
export type Dialogfeld = {
|
export type Dialogfeld = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -71,7 +72,10 @@ export function dialog(optionen: {
|
|||||||
const eingaben = new Map<string, HTMLInputElement | HTMLSelectElement>();
|
const eingaben = new Map<string, HTMLInputElement | HTMLSelectElement>();
|
||||||
|
|
||||||
(optionen.felder ?? []).forEach(f => {
|
(optionen.felder ?? []).forEach(f => {
|
||||||
const block = document.createElement('label');
|
// **Ein `<label>` nur dort, wo genau ein Bedienelement darin sitzt.**
|
||||||
|
// Umschliesst es zusätzlich einen Knopf, löst jeder Klick darauf auch
|
||||||
|
// das Label aus und schiebt den Fokus ins Textfeld zurück.
|
||||||
|
const block = document.createElement(f.art === 'ordner' ? 'div' : 'label');
|
||||||
block.className = 'dlg-feld';
|
block.className = 'dlg-feld';
|
||||||
|
|
||||||
const titel = document.createElement('span');
|
const titel = document.createElement('span');
|
||||||
@@ -100,6 +104,14 @@ export function dialog(optionen: {
|
|||||||
block.prepend(e);
|
block.prepend(e);
|
||||||
titel.remove();
|
titel.remove();
|
||||||
block.append(titel);
|
block.append(titel);
|
||||||
|
} else if (f.art === 'ordner') {
|
||||||
|
const e = document.createElement('input');
|
||||||
|
e.className = 'feld';
|
||||||
|
e.type = 'text';
|
||||||
|
e.value = f.wert ?? '';
|
||||||
|
e.placeholder = f.platzhalter ?? '';
|
||||||
|
eingaben.set(f.name, e);
|
||||||
|
block.appendChild(ordnerfeld(e));
|
||||||
} else {
|
} else {
|
||||||
const e = document.createElement('input');
|
const e = document.createElement('input');
|
||||||
e.className = 'feld';
|
e.className = 'feld';
|
||||||
@@ -179,6 +191,157 @@ export function dialog(optionen: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein Textfeld mit Ordnerauswahl daneben.
|
||||||
|
*
|
||||||
|
* **Das Feld bleibt tippbar.** Wer den Pfad kennt, ist damit schneller als
|
||||||
|
* mit jedem Durchklicken — und bei einem tiefen Baum ist es die einzige
|
||||||
|
* geduldschonende Möglichkeit. Der Knopf ist ein Angebot, keine Pflicht.
|
||||||
|
*
|
||||||
|
* **Die Auswahl klappt im selben Dialog auf**, statt einen zweiten darüber
|
||||||
|
* zu legen. Zwei gestapelte Dialoge sind beim Schliessen immer eine Frage
|
||||||
|
* zu viel: welcher geht jetzt zu?
|
||||||
|
*/
|
||||||
|
function ordnerfeld(eingabe: HTMLInputElement): HTMLElement {
|
||||||
|
const huelle = document.createElement('div');
|
||||||
|
huelle.className = 'ordnerfeld';
|
||||||
|
|
||||||
|
const zeile = document.createElement('div');
|
||||||
|
zeile.className = 'ordnerzeile';
|
||||||
|
const auf = knopf('Durchsuchen', 'btn');
|
||||||
|
auf.type = 'button';
|
||||||
|
auf.prepend(rahmenIcon(icons.ordner));
|
||||||
|
zeile.append(eingabe, auf);
|
||||||
|
|
||||||
|
const baum = document.createElement('div');
|
||||||
|
baum.className = 'ordnerbaum hidden';
|
||||||
|
|
||||||
|
const kopf = document.createElement('div');
|
||||||
|
kopf.className = 'ordnerkopf';
|
||||||
|
const hoch = document.createElement('button');
|
||||||
|
hoch.type = 'button';
|
||||||
|
hoch.className = 'rund';
|
||||||
|
hoch.innerHTML = icons.hoch;
|
||||||
|
hoch.setAttribute('aria-label', 'Eine Ebene höher');
|
||||||
|
const pfad = document.createElement('div');
|
||||||
|
pfad.className = 'ordnerpfad';
|
||||||
|
kopf.append(hoch, pfad);
|
||||||
|
|
||||||
|
const liste = document.createElement('div');
|
||||||
|
liste.className = 'ordnerliste';
|
||||||
|
|
||||||
|
const fuss = document.createElement('div');
|
||||||
|
fuss.className = 'ordnerfuss';
|
||||||
|
const waehlen = knopf('Diesen Ordner wählen', 'btn btn-primary');
|
||||||
|
waehlen.type = 'button';
|
||||||
|
fuss.appendChild(waehlen);
|
||||||
|
|
||||||
|
baum.append(kopf, liste, fuss);
|
||||||
|
huelle.append(zeile, baum);
|
||||||
|
|
||||||
|
let hier: Ordnerstand | null = null;
|
||||||
|
|
||||||
|
async function gehe(ziel?: string): Promise<void> {
|
||||||
|
liste.innerHTML = '<div class="spinner"></div>';
|
||||||
|
try {
|
||||||
|
hier = await ordner(ziel);
|
||||||
|
} catch (fehler) {
|
||||||
|
liste.innerHTML = '';
|
||||||
|
liste.appendChild(hinweiszeile((fehler as Error).message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zeichne();
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeichne(): void {
|
||||||
|
if (!hier) return;
|
||||||
|
hoch.disabled = hier.parent === null;
|
||||||
|
waehlen.textContent = `„${hier.path}" wählen`;
|
||||||
|
|
||||||
|
// Der Pfad in anklickbaren Stücken — ein Sprung nach oben ist damit
|
||||||
|
// einer, nicht fünf.
|
||||||
|
pfad.innerHTML = '';
|
||||||
|
const teile = hier.path.split('/').filter(t => t.length > 0);
|
||||||
|
// Die Wurzel trägt ihren Strich selbst und bekommt deshalb keinen
|
||||||
|
// Trenner angehängt — sonst steht dort „/ / media".
|
||||||
|
const wurzel = pfadstueck('/', '/');
|
||||||
|
wurzel.classList.add('wurzel');
|
||||||
|
pfad.appendChild(wurzel);
|
||||||
|
let bisher = '';
|
||||||
|
teile.forEach(t => {
|
||||||
|
bisher += '/' + t;
|
||||||
|
pfad.appendChild(pfadstueck(t, bisher));
|
||||||
|
});
|
||||||
|
|
||||||
|
liste.innerHTML = '';
|
||||||
|
if (hier.entries.length === 0) {
|
||||||
|
liste.appendChild(hinweiszeile(hier.note ?? 'Keine Unterordner.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hier.entries.forEach(e => {
|
||||||
|
const z = document.createElement('button');
|
||||||
|
z.type = 'button';
|
||||||
|
z.className = 'ordnereintrag';
|
||||||
|
z.innerHTML = icons.ordner;
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.textContent = e.name;
|
||||||
|
z.appendChild(name);
|
||||||
|
z.onclick = () => void gehe(e.path);
|
||||||
|
liste.appendChild(z);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pfadstueck(text: string, ziel: string): HTMLElement {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'pfadstueck';
|
||||||
|
b.textContent = text;
|
||||||
|
b.onclick = () => void gehe(ziel);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hinweiszeile(text: string): HTMLElement {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.className = 'winzig';
|
||||||
|
d.textContent = text;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
auf.onclick = () => {
|
||||||
|
const offen = !baum.classList.toggle('hidden');
|
||||||
|
auf.textContent = offen ? 'Zuklappen' : 'Durchsuchen';
|
||||||
|
auf.prepend(rahmenIcon(icons.ordner));
|
||||||
|
// Da anfangen, wo im Feld schon etwas steht — sonst beim Server.
|
||||||
|
if (offen && !hier) void gehe(eingabe.value.trim() || undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
hoch.onclick = () => { if (hier?.parent) void gehe(hier.parent); };
|
||||||
|
|
||||||
|
waehlen.onclick = () => {
|
||||||
|
if (!hier) return;
|
||||||
|
eingabe.value = hier.path;
|
||||||
|
baum.classList.add('hidden');
|
||||||
|
auf.textContent = 'Durchsuchen';
|
||||||
|
auf.prepend(rahmenIcon(icons.ordner));
|
||||||
|
eingabe.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// **Enter darf hier nicht den Dialog abschicken.** Der Dialog lauscht
|
||||||
|
// auf seinem Körper und drückt bei Enter den Bestätigen-Knopf; in der
|
||||||
|
// Ordnerauswahl soll Enter den fokussierten Ordner öffnen und sonst
|
||||||
|
// nichts.
|
||||||
|
baum.addEventListener('keydown', e => { if (e.key === 'Enter') e.stopPropagation(); });
|
||||||
|
|
||||||
|
return huelle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Icon als Element — `innerHTML` würde den Text des Knopfes löschen. */
|
||||||
|
function rahmenIcon(svg: string): HTMLElement {
|
||||||
|
const s = document.createElement('span');
|
||||||
|
s.className = 'icon';
|
||||||
|
s.innerHTML = svg;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fragt einmal nach — für alles, was etwas wegnimmt. */
|
/** Fragt einmal nach — für alles, was etwas wegnimmt. */
|
||||||
export function nachfragen(optionen: {
|
export function nachfragen(optionen: {
|
||||||
titel: string;
|
titel: string;
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ const rahmen = (inhalt: string, gefuellt = false): string =>
|
|||||||
|
|
||||||
export const icons = {
|
export const icons = {
|
||||||
start: rahmen('<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>'),
|
start: rahmen('<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>'),
|
||||||
|
/** Ordner — fürs Durchklicken durch das Dateisystem. */
|
||||||
|
ordner: rahmen('<path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4.3a1.5 1.5 0 0 1 1.07.45L11.4 7.6"'
|
||||||
|
+ ' /><path d="M3 7.5h16.5A1.5 1.5 0 0 1 21 9v8.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5Z"/>'),
|
||||||
|
/** Winkel nach oben — eine Ebene zurück im Baum. */
|
||||||
|
hoch: rahmen('<path d="m6 14 6-6 6 6"/>'),
|
||||||
/** Filmstreifen — für eine Filmmediathek. */
|
/** Filmstreifen — für eine Filmmediathek. */
|
||||||
filme: rahmen('<rect x="3" y="4" width="18" height="16" rx="2"/>'
|
filme: rahmen('<rect x="3" y="4" width="18" height="16" rx="2"/>'
|
||||||
+ '<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>'),
|
+ '<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>'),
|
||||||
|
|||||||
@@ -567,3 +567,70 @@ h2 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; margin: 0 0 var(
|
|||||||
.detail-text h1 { font-size: 26px; }
|
.detail-text h1 { font-size: 26px; }
|
||||||
#topbar .suchfeld { max-width: none; }
|
#topbar .suchfeld { max-width: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Ordnerauswahl ------------------------------------------------------
|
||||||
|
*
|
||||||
|
* Klappt im Dialog auf, statt einen zweiten darüberzulegen. Die Höhe ist
|
||||||
|
* begrenzt und scrollt: ein Verzeichnis mit zweihundert Unterordnern würde
|
||||||
|
* den Dialog sonst über den Bildschirmrand schieben. */
|
||||||
|
|
||||||
|
.ordnerfeld { display: flex; flex-direction: column; gap: var(--s-snug); }
|
||||||
|
|
||||||
|
.ordnerzeile { display: flex; gap: var(--s-snug); }
|
||||||
|
.ordnerzeile .feld { flex: 1; min-width: 0; }
|
||||||
|
.ordnerzeile .btn { flex: none; gap: 6px; }
|
||||||
|
|
||||||
|
.ordnerbaum {
|
||||||
|
border: 1px solid var(--line); border-radius: var(--r-control);
|
||||||
|
background: var(--canvas); overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ordnerkopf {
|
||||||
|
display: flex; align-items: center; gap: var(--s-snug);
|
||||||
|
padding: var(--s-snug); border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.ordnerkopf .rund { width: 30px; height: 30px; flex: none; }
|
||||||
|
.ordnerkopf .rund svg { width: 16px; height: 16px; }
|
||||||
|
.ordnerkopf .rund:disabled { opacity: .35; }
|
||||||
|
|
||||||
|
/* Der Pfad scrollt waagerecht statt umzubrechen — ein tiefer Pfad soll die
|
||||||
|
Kopfzeile nicht dreizeilig machen. */
|
||||||
|
.ordnerpfad {
|
||||||
|
display: flex; align-items: center; gap: 2px;
|
||||||
|
overflow-x: auto; scrollbar-width: none; min-width: 0;
|
||||||
|
}
|
||||||
|
.ordnerpfad::-webkit-scrollbar { display: none; }
|
||||||
|
.ordnerpfad .pfadstueck {
|
||||||
|
background: none; border: 0; padding: 2px 5px; border-radius: 4px;
|
||||||
|
color: var(--text-dim); font-size: 12px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ordnerpfad .pfadstueck:hover { background: var(--elevated); color: var(--text); }
|
||||||
|
.ordnerpfad .pfadstueck:not(:last-child)::after { content: '/'; margin-left: 5px; color: var(--text-faint); }
|
||||||
|
.ordnerpfad .pfadstueck.wurzel::after { content: none; }
|
||||||
|
|
||||||
|
.ordnerliste {
|
||||||
|
max-height: 220px; overflow-y: auto;
|
||||||
|
display: flex; flex-direction: column; padding: var(--s-tight);
|
||||||
|
}
|
||||||
|
.ordnerliste .winzig { padding: var(--s-regular); }
|
||||||
|
|
||||||
|
.ordnereintrag {
|
||||||
|
display: flex; align-items: center; gap: var(--s-snug);
|
||||||
|
background: none; border: 0; border-radius: 5px;
|
||||||
|
padding: 7px var(--s-snug); text-align: left;
|
||||||
|
color: var(--text-dim); font-size: 13px;
|
||||||
|
}
|
||||||
|
.ordnereintrag svg { width: 17px; height: 17px; color: var(--text-faint); }
|
||||||
|
.ordnereintrag:hover { background: var(--elevated); color: var(--text); }
|
||||||
|
.ordnereintrag:hover svg { color: var(--accent); }
|
||||||
|
|
||||||
|
.ordnerfuss {
|
||||||
|
padding: var(--s-snug); border-top: 1px solid var(--line);
|
||||||
|
display: flex; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
/* Der Knopf trägt den ganzen Pfad — bei einem langen muss er kürzen dürfen,
|
||||||
|
sonst wächst der Dialog mit. */
|
||||||
|
.ordnerfuss .btn {
|
||||||
|
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
|
|||||||
auswahl: [{ wert: 'Movies', text: 'Filme' }, { wert: 'Shows', text: 'Serien' }],
|
auswahl: [{ wert: 'Movies', text: 'Filme' }, { wert: 'Shows', text: 'Serien' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'pfad', beschriftung: 'Ordner',
|
name: 'pfad', beschriftung: 'Ordner', art: 'ordner',
|
||||||
platzhalter: '/media/Filme',
|
platzhalter: '/media/Filme',
|
||||||
hinweis: 'Der Pfad im Container, nicht der auf dem NAS — '
|
hinweis: 'Der Pfad im Container, nicht der auf dem NAS — '
|
||||||
+ 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.',
|
+ 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.',
|
||||||
|
|||||||
Reference in New Issue
Block a user