Initial Commit
This commit is contained in:
commit
6d2e379a0c
41 changed files with 5064 additions and 0 deletions
BIN
public/img/palico-index.png
Normal file
BIN
public/img/palico-index.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/img/palico-jukebox.png
Normal file
BIN
public/img/palico-jukebox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
37
public/index.html
Normal file
37
public/index.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Palico Bot</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/style/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="hero hero--home">
|
||||
<div class="hero__visual">
|
||||
<img src="/img/palico-index.png" alt="Palico Bot" />
|
||||
</div>
|
||||
|
||||
<div class="hero__content">
|
||||
<p class="pill">Palico Bot</p>
|
||||
<h1>Bienvenue sur la webapp</h1>
|
||||
<p>
|
||||
Lance de la musique sur le Discord et pilote la playlist du salon vocal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section class="panel panel--glass">
|
||||
<div class="feature-grid">
|
||||
<a class="feature-grid__card" href="/jukebox/">
|
||||
<p class="pill">Le Jukebox du Palico Bot</p>
|
||||
<p class="panel__subtitle">
|
||||
Upload, gère et pilote la playlist du salon vocal en direct.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
182
public/jukebox.html
Normal file
182
public/jukebox.html
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Palico Jukebox</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/style/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="hero">
|
||||
<div class="hero__visual">
|
||||
<img src="/img/palico-jukebox.png" alt="Palico Jukebox" />
|
||||
</div>
|
||||
<div class="hero__content">
|
||||
<p class="pill">Le Jukebox du Palico Bot</p>
|
||||
<h1>Un peu de son pour le Discord !</h1>
|
||||
<p>
|
||||
Tu veux chill avec les autres sur un petit fond musical ?<br />
|
||||
T'inquietes pas, je peux m'occuper de ca !<br />
|
||||
<br />
|
||||
Uploade des fichiers et gère la playlist depuis cette interface web.
|
||||
Tout est synchronisé avec le salon vocal.
|
||||
</p>
|
||||
<div class="hero__actions">
|
||||
<button id="heroUploadBtn" class="btn">Ajouter une musique</button>
|
||||
<button id="heroQueueBtn" class="btn btn--ghost">Voir la file</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero__auth">
|
||||
<div class="panel panel--glass">
|
||||
<h2>Authentification</h2>
|
||||
<p class="panel__subtitle">
|
||||
Ca sera stocké sur ton navigateur si tu sauvegardes !
|
||||
</p>
|
||||
<form id="settingsForm">
|
||||
<div class="grid grid--two">
|
||||
<div>
|
||||
<label for="displayName">Nom affiché</label>
|
||||
<input
|
||||
type="text"
|
||||
id="displayName"
|
||||
name="displayName"
|
||||
placeholder="Shinuwa"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="apiKey">Mot de passe</label>
|
||||
<input
|
||||
type="text"
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
placeholder="Mot de passe"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">Sauvegarder</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div id="status" class="status status--info" hidden></div>
|
||||
|
||||
<section class="panel panel--glass">
|
||||
<div class="panel__header">
|
||||
<div>
|
||||
<h2>Lecture en cours</h2>
|
||||
<p class="panel__subtitle">
|
||||
État en temps réel du player Discord. Contrôle instantané.
|
||||
</p>
|
||||
</div>
|
||||
<button id="refreshBtn" class="btn btn--ghost btn--small">Rafraîchir</button>
|
||||
</div>
|
||||
<div id="currentTrack" class="player-current player-current--empty">
|
||||
En attente de lecture...
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button class="btn" data-player-action="play">Play</button>
|
||||
<button
|
||||
id="pauseResumeBtn"
|
||||
class="btn btn--secondary"
|
||||
data-player-action="pause"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
<button class="btn" data-player-action="skip">Suivante</button>
|
||||
<button class="btn btn--danger" data-player-action="stop">
|
||||
Stop & vider la file
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel--glass" id="queueSection">
|
||||
<div class="panel__header">
|
||||
<div>
|
||||
<h2>File d'attente</h2>
|
||||
<p class="panel__subtitle">
|
||||
Réorganise les morceaux, supprime-les ou ajoute ceux de la
|
||||
bibliothèque.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul id="queueList" class="list list--stack"></ul>
|
||||
<p id="queueEmpty" class="text--muted">Aucun morceau en attente.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel panel--glass">
|
||||
<h2>Bibliothèque permanente</h2>
|
||||
<p class="panel__subtitle">
|
||||
Les fichiers conservés sont listés ici. Ajoute-les à la file ou
|
||||
supprime-les.
|
||||
</p>
|
||||
<ul id="libraryList" class="list list--stack"></ul>
|
||||
<p id="libraryEmpty" class="text--muted">
|
||||
Aucun fichier permanent enregistré.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel panel--glass" id="uploadSection">
|
||||
<div class="panel__header">
|
||||
<div>
|
||||
<h2>Uploader une musique</h2>
|
||||
<p class="panel__subtitle">
|
||||
Formats audio supportés par FFmpeg. Les fichiers temporaires sont
|
||||
supprimés automatiquement après lecture.
|
||||
</p>
|
||||
</div>
|
||||
<div class="badge">Drag & drop supporté</div>
|
||||
</div>
|
||||
<form id="uploadForm">
|
||||
<label for="musicFile">Fichier audio</label>
|
||||
<input
|
||||
type="file"
|
||||
id="musicFile"
|
||||
name="musicFile"
|
||||
accept="audio/*"
|
||||
required
|
||||
/>
|
||||
|
||||
<label for="uploadNotes" class="u-mt-1">Commentaire (optionnel)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="uploadNotes"
|
||||
name="notes"
|
||||
placeholder="Titre personnalisé"
|
||||
/>
|
||||
|
||||
<div class="grid grid--two u-mt-1">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" id="permanentFile" class="form-checkbox__input" />
|
||||
<span>Conserver dans la bibliothèque après lecture</span>
|
||||
</label>
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" id="enqueueFile" checked class="form-checkbox__input" />
|
||||
<span>Ajouter directement à la file d'attente</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn u-mt-1">Uploader</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="hero__actions hero__actions--centered">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--ghost btn--small"
|
||||
onclick="window.location.href = '/'"
|
||||
>
|
||||
Retour à l'accueil
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/scripts/jukebox.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
118
public/scripts/common.js
Normal file
118
public/scripts/common.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
const API_KEY_STORAGE_KEY = "palico-api-key";
|
||||
const DISPLAY_NAME_STORAGE_KEY = "palico-display-name";
|
||||
|
||||
export function getStoredApiKey() {
|
||||
return localStorage.getItem(API_KEY_STORAGE_KEY) || "";
|
||||
}
|
||||
|
||||
export function setStoredApiKey(value) {
|
||||
localStorage.setItem(API_KEY_STORAGE_KEY, value || "");
|
||||
}
|
||||
|
||||
export function getStoredDisplayName() {
|
||||
return localStorage.getItem(DISPLAY_NAME_STORAGE_KEY) || "";
|
||||
}
|
||||
|
||||
export function setStoredDisplayName(value) {
|
||||
localStorage.setItem(DISPLAY_NAME_STORAGE_KEY, value || "");
|
||||
}
|
||||
|
||||
export function createStatusManager(statusElement) {
|
||||
let hideTimeout;
|
||||
|
||||
function setStatus(message, type = "info", autoHide = true) {
|
||||
if (!statusElement) return;
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = `status status--${type}`;
|
||||
statusElement.hidden = false;
|
||||
|
||||
if (autoHide) {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = setTimeout(() => {
|
||||
statusElement.hidden = true;
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function clearStatus() {
|
||||
if (!statusElement) return;
|
||||
statusElement.hidden = true;
|
||||
statusElement.textContent = "";
|
||||
clearTimeout(hideTimeout);
|
||||
}
|
||||
|
||||
return { setStatus, clearStatus };
|
||||
}
|
||||
|
||||
export function escapeHtml(text = "") {
|
||||
return text
|
||||
.toString()
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function formatBytes(bytes = 0) {
|
||||
if (!Number(bytes)) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const index = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, index)).toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export function formatDate(value) {
|
||||
if (!value) return "?";
|
||||
try {
|
||||
return new Date(value).toLocaleString();
|
||||
} catch (_) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiClient({ getApiKey, basePath = "" } = {}) {
|
||||
const normalizedBase = (basePath || "").replace(/\/$/, "");
|
||||
|
||||
return async function apiFetch(path, options = {}) {
|
||||
const url = path.startsWith("http")
|
||||
? path
|
||||
: `${normalizedBase}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||
|
||||
const fetchOptions = {
|
||||
method: options.method || "GET",
|
||||
headers: new Headers(options.headers || {}),
|
||||
};
|
||||
|
||||
const apiKey = typeof getApiKey === "function" ? getApiKey() : null;
|
||||
if (apiKey) {
|
||||
fetchOptions.headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
}
|
||||
|
||||
if (options.body instanceof FormData) {
|
||||
fetchOptions.body = options.body;
|
||||
} else if (options.body) {
|
||||
if (!fetchOptions.headers.has("Content-Type")) {
|
||||
fetchOptions.headers.set("Content-Type", "application/json");
|
||||
}
|
||||
fetchOptions.body =
|
||||
typeof options.body === "string" ? options.body : JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
let payload = null;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
payload = await response.json();
|
||||
} else {
|
||||
payload = await response.text();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = payload?.error || payload?.message || response.statusText;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
}
|
||||
319
public/scripts/jukebox.js
Normal file
319
public/scripts/jukebox.js
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import {
|
||||
createApiClient,
|
||||
createStatusManager,
|
||||
escapeHtml,
|
||||
getStoredApiKey,
|
||||
getStoredDisplayName,
|
||||
formatBytes,
|
||||
formatDate,
|
||||
setStoredApiKey,
|
||||
setStoredDisplayName,
|
||||
} from "./common.js";
|
||||
|
||||
const state = {
|
||||
apiKey: getStoredApiKey(),
|
||||
displayName: getStoredDisplayName(),
|
||||
queue: [],
|
||||
current: null,
|
||||
library: [],
|
||||
status: "idle",
|
||||
};
|
||||
|
||||
const elements = {
|
||||
status: document.getElementById("status"),
|
||||
apiKeyInput: document.getElementById("apiKey"),
|
||||
displayNameInput: document.getElementById("displayName"),
|
||||
settingsForm: document.getElementById("settingsForm"),
|
||||
uploadForm: document.getElementById("uploadForm"),
|
||||
fileInput: document.getElementById("musicFile"),
|
||||
notesInput: document.getElementById("uploadNotes"),
|
||||
permanentCheckbox: document.getElementById("permanentFile"),
|
||||
enqueueCheckbox: document.getElementById("enqueueFile"),
|
||||
currentTrack: document.getElementById("currentTrack"),
|
||||
queueList: document.getElementById("queueList"),
|
||||
queueEmpty: document.getElementById("queueEmpty"),
|
||||
libraryList: document.getElementById("libraryList"),
|
||||
libraryEmpty: document.getElementById("libraryEmpty"),
|
||||
refreshBtn: document.getElementById("refreshBtn"),
|
||||
pauseResumeBtn: document.getElementById("pauseResumeBtn"),
|
||||
playerButtons: document.querySelectorAll("[data-player-action]"),
|
||||
heroUploadBtn: document.getElementById("heroUploadBtn"),
|
||||
heroQueueBtn: document.getElementById("heroQueueBtn"),
|
||||
uploadSection: document.getElementById("uploadSection"),
|
||||
queueSection: document.getElementById("queueSection"),
|
||||
};
|
||||
|
||||
let pollIntervalId = null;
|
||||
|
||||
const { setStatus, clearStatus } = createStatusManager(elements.status);
|
||||
const apiFetch = createApiClient({
|
||||
basePath: "/api",
|
||||
getApiKey: () => state.apiKey,
|
||||
});
|
||||
|
||||
async function refreshAll({ silent = false } = {}) {
|
||||
if (!silent) {
|
||||
clearStatus();
|
||||
}
|
||||
|
||||
try {
|
||||
const [queueData, libraryData] = await Promise.all([
|
||||
apiFetch("/queue"),
|
||||
apiFetch("/library"),
|
||||
]);
|
||||
|
||||
state.current = queueData.current;
|
||||
state.queue = queueData.upcoming || [];
|
||||
state.library = libraryData.tracks || [];
|
||||
state.status = queueData.status || "idle";
|
||||
|
||||
renderCurrentTrack();
|
||||
renderQueue();
|
||||
renderLibrary();
|
||||
updatePauseResumeButton();
|
||||
} catch (error) {
|
||||
setStatus(`Erreur: ${error.message}`, "error", false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurrentTrack() {
|
||||
if (!elements.currentTrack) return;
|
||||
if (!state.current) {
|
||||
elements.currentTrack.classList.add("player-current--empty");
|
||||
elements.currentTrack.innerHTML = "En attente de lecture...";
|
||||
return;
|
||||
}
|
||||
|
||||
elements.currentTrack.classList.remove("player-current--empty");
|
||||
const track = state.current;
|
||||
const permanence = track.permanent ? "Permanent" : "Temporaire";
|
||||
elements.currentTrack.innerHTML = `
|
||||
<div class="media-item__title">${escapeHtml(track.title || track.originalName)}</div>
|
||||
<div class="media-item__meta">
|
||||
${permanence} • ${formatBytes(track.size)} • Ajouté par ${escapeHtml(
|
||||
track.uploader || "inconnu"
|
||||
)}
|
||||
</div>
|
||||
<div class="media-item__meta">Commencé à ${formatDate(track.createdAt)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderQueue() {
|
||||
if (!elements.queueList) return;
|
||||
elements.queueList.innerHTML = "";
|
||||
|
||||
if (!state.queue || state.queue.length === 0) {
|
||||
elements.queueEmpty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
elements.queueEmpty.style.display = "none";
|
||||
|
||||
state.queue.forEach((track, index) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "media-item media-item--queue";
|
||||
const permanence = track.permanent ? "Permanent" : "Temporaire";
|
||||
li.innerHTML = `
|
||||
<div class="media-item__info">
|
||||
<div class="media-item__title">${escapeHtml(track.title || track.originalName)}</div>
|
||||
<div class="media-item__meta">
|
||||
${permanence} • ${formatBytes(track.size)} • Ajouté par ${escapeHtml(
|
||||
track.uploader || "inconnu"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-item__actions">
|
||||
<button class="btn btn--secondary" data-action="up" data-track="${track.id}">▲</button>
|
||||
<button class="btn btn--secondary" data-action="down" data-track="${track.id}">▼</button>
|
||||
<button class="btn btn--danger" data-action="remove" data-track="${track.id}">Supprimer</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
li.querySelectorAll("button").forEach((btn) => {
|
||||
btn.addEventListener("click", (event) => {
|
||||
const action = event.currentTarget.dataset.action;
|
||||
const trackId = event.currentTarget.dataset.track;
|
||||
handleQueueAction(action, trackId, index);
|
||||
});
|
||||
});
|
||||
|
||||
elements.queueList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLibrary() {
|
||||
if (!elements.libraryList) return;
|
||||
elements.libraryList.innerHTML = "";
|
||||
|
||||
if (!state.library || state.library.length === 0) {
|
||||
elements.libraryEmpty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
elements.libraryEmpty.style.display = "none";
|
||||
|
||||
state.library.forEach((track) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "media-item media-item--library";
|
||||
li.innerHTML = `
|
||||
<div class="media-item__info">
|
||||
<div class="media-item__title">${escapeHtml(track.originalName)}</div>
|
||||
<div class="media-item__meta">
|
||||
${formatBytes(track.size)} • Ajouté le ${formatDate(track.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-item__actions">
|
||||
<button class="btn" data-library-action="enqueue" data-library-id="${track.id}">Ajouter</button>
|
||||
<button class="btn btn--danger" data-library-action="delete" data-library-id="${track.id}">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
li.querySelectorAll("button").forEach((btn) => {
|
||||
btn.addEventListener("click", (event) => {
|
||||
const action = event.currentTarget.dataset.libraryAction;
|
||||
const libraryId = event.currentTarget.dataset.libraryId;
|
||||
handleLibraryAction(action, libraryId);
|
||||
});
|
||||
});
|
||||
|
||||
elements.libraryList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function updatePauseResumeButton() {
|
||||
if (!elements.pauseResumeBtn) return;
|
||||
const normalizedStatus =
|
||||
typeof state.status === "string" ? state.status.toLowerCase() : "idle";
|
||||
const isPaused = normalizedStatus === "paused";
|
||||
const isPlaying = normalizedStatus === "playing";
|
||||
|
||||
if (isPaused) {
|
||||
elements.pauseResumeBtn.textContent = "Reprendre";
|
||||
elements.pauseResumeBtn.dataset.playerAction = "resume";
|
||||
} else {
|
||||
elements.pauseResumeBtn.textContent = "Pause";
|
||||
elements.pauseResumeBtn.dataset.playerAction = "pause";
|
||||
}
|
||||
|
||||
const actionable = isPaused || isPlaying;
|
||||
elements.pauseResumeBtn.disabled = !actionable;
|
||||
}
|
||||
|
||||
async function handleQueueAction(action, trackId, index) {
|
||||
try {
|
||||
if (action === "remove") {
|
||||
await apiFetch(`/queue/${trackId}`, { method: "DELETE" });
|
||||
} else if (action === "up" || action === "down") {
|
||||
const delta = action === "up" ? -1 : 1;
|
||||
const newPosition = Math.max(0, Math.min(index + delta, state.queue.length - 1));
|
||||
if (newPosition === index) return;
|
||||
await apiFetch(`/queue/${trackId}/move`, {
|
||||
method: "PATCH",
|
||||
body: { position: newPosition },
|
||||
});
|
||||
}
|
||||
await refreshAll({ silent: true });
|
||||
} catch (error) {
|
||||
setStatus(`Impossible de mettre à jour la file: ${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLibraryAction(action, libraryId) {
|
||||
try {
|
||||
if (action === "enqueue") {
|
||||
await apiFetch(`/library/${libraryId}/enqueue`, {
|
||||
method: "POST",
|
||||
body: { requestedBy: state.displayName || "web-ui" },
|
||||
});
|
||||
} else if (action === "delete") {
|
||||
if (!confirm("Supprimer ce fichier permanent ?")) {
|
||||
return;
|
||||
}
|
||||
await apiFetch(`/library/${libraryId}`, { method: "DELETE" });
|
||||
}
|
||||
await refreshAll({ silent: true });
|
||||
} catch (error) {
|
||||
setStatus(`Action bibliothèque impossible: ${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
clearInterval(pollIntervalId);
|
||||
pollIntervalId = setInterval(() => refreshAll({ silent: true }), 5000);
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
elements.settingsForm?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
state.apiKey = elements.apiKeyInput.value.trim();
|
||||
state.displayName = elements.displayNameInput.value.trim();
|
||||
setStoredApiKey(state.apiKey);
|
||||
setStoredDisplayName(state.displayName);
|
||||
setStatus("Paramètres sauvegardés", "success");
|
||||
refreshAll({ silent: true });
|
||||
});
|
||||
|
||||
elements.uploadForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const file = elements.fileInput.files[0];
|
||||
if (!file) {
|
||||
setStatus("Merci de sélectionner un fichier audio", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = new FormData();
|
||||
data.append("file", file);
|
||||
data.append("permanent", elements.permanentCheckbox.checked ? "true" : "false");
|
||||
data.append("enqueue", elements.enqueueCheckbox.checked ? "true" : "false");
|
||||
if (state.displayName) {
|
||||
data.append("uploader", state.displayName);
|
||||
}
|
||||
if (elements.notesInput.value) {
|
||||
data.append("notes", elements.notesInput.value.trim());
|
||||
}
|
||||
|
||||
setStatus("Upload en cours...", "info", false);
|
||||
await apiFetch("/uploads", { method: "POST", body: data });
|
||||
elements.uploadForm.reset();
|
||||
elements.enqueueCheckbox.checked = true;
|
||||
setStatus("Fichier envoyé avec succès", "success");
|
||||
await refreshAll({ silent: true });
|
||||
} catch (error) {
|
||||
setStatus(`Échec de l'upload: ${error.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
elements.refreshBtn?.addEventListener("click", () => refreshAll());
|
||||
elements.heroUploadBtn?.addEventListener("click", () =>
|
||||
elements.uploadSection?.scrollIntoView({ behavior: "smooth" })
|
||||
);
|
||||
elements.heroQueueBtn?.addEventListener("click", () =>
|
||||
elements.queueSection?.scrollIntoView({ behavior: "smooth" })
|
||||
);
|
||||
|
||||
elements.playerButtons?.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const action = button.dataset.playerAction;
|
||||
try {
|
||||
await apiFetch(`/player/${action}`, { method: "POST" });
|
||||
await refreshAll({ silent: true });
|
||||
} catch (error) {
|
||||
setStatus(`Action player impossible: ${error.message}`, "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
elements.apiKeyInput.value = state.apiKey;
|
||||
elements.displayNameInput.value = state.displayName;
|
||||
bindEvents();
|
||||
updatePauseResumeButton();
|
||||
refreshAll();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
initialize();
|
||||
415
public/style/styles.css
Normal file
415
public/style/styles.css
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #06030d;
|
||||
--bg-card: rgba(22, 14, 36, 0.9);
|
||||
--bg-glass: rgba(42, 25, 66, 0.65);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--accent: #b259ff;
|
||||
--accent-strong: #d66bff;
|
||||
--muted: #cbc4d5;
|
||||
--muted-dark: #8c7ea7;
|
||||
--danger: #ff4d6d;
|
||||
--success: #59ffa7;
|
||||
--gradient: radial-gradient(circle at 20% 20%, #2d1a49, transparent 45%),
|
||||
radial-gradient(circle at 80% 0%, #551e8c, transparent 40%),
|
||||
radial-gradient(circle at 50% 80%, #150b28, transparent 30%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--gradient), #050109;
|
||||
color: #f7f1ff;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem 4rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.text--muted {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 3rem 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero--home {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
gap: 2.75rem;
|
||||
}
|
||||
|
||||
.hero--home .hero__visual img {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.hero__content {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.hero__content h1 {
|
||||
font-size: 2.2rem;
|
||||
line-height: 1.1;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero__content p {
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.hero__actions--centered {
|
||||
justify-content: center;
|
||||
margin: 2rem auto 3rem;
|
||||
}
|
||||
|
||||
.hero__visual {
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero__visual img {
|
||||
max-width: 200px;
|
||||
width: 100%;
|
||||
filter: drop-shadow(0 25px 45px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
|
||||
.hero__visual--large img {
|
||||
max-width: 270px;
|
||||
}
|
||||
|
||||
.hero__auth {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 18px;
|
||||
padding: 1.75rem;
|
||||
margin-bottom: 1.75rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel--glass {
|
||||
background: var(--bg-glass);
|
||||
box-shadow: 0 20px 38px rgba(2, 0, 8, 0.45);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.panel__subtitle {
|
||||
color: var(--muted);
|
||||
margin-top: 0.35rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.35rem 0.9rem;
|
||||
background: rgba(178, 89, 255, 0.2);
|
||||
color: var(--accent);
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="file"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: 2px solid rgba(178, 89, 255, 0.5);
|
||||
border-color: rgba(178, 89, 255, 0.6);
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.form-checkbox__input {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.65rem 1.55rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(120deg, var(--accent), var(--accent-strong));
|
||||
color: #08010f;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 25px rgba(178, 89, 255, 0.35);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background: linear-gradient(120deg, #ff4d6d, #ff89a5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn--small {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.grid--two {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.u-mt-1 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.95rem;
|
||||
margin: 1rem 0 2rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.status--info {
|
||||
background: rgba(178, 89, 255, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status--error {
|
||||
background: rgba(255, 77, 109, 0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status--success {
|
||||
background: rgba(89, 255, 167, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list--stack > li {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.media-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.9rem 0;
|
||||
}
|
||||
|
||||
.media-item__info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.media-item__title {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.media-item__meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.media-item__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.player-current {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 16px;
|
||||
padding: 1.2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.player-current--empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.feature-grid__card {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: var(--bg-card);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.feature-grid__card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 15px 30px rgba(2, 0, 8, 0.45);
|
||||
}
|
||||
|
||||
.feature-grid__card .pill {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero,
|
||||
.hero--home {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero__content p {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.btn,
|
||||
input[type="text"],
|
||||
input[type="file"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
417
public/style/styles.scss
Normal file
417
public/style/styles.scss
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #06030d;
|
||||
--bg-card: rgba(22, 14, 36, 0.9);
|
||||
--bg-glass: rgba(42, 25, 66, 0.65);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--accent: #b259ff;
|
||||
--accent-strong: #d66bff;
|
||||
--muted: #cbc4d5;
|
||||
--muted-dark: #8c7ea7;
|
||||
--danger: #ff4d6d;
|
||||
--success: #59ffa7;
|
||||
--gradient: radial-gradient(circle at 20% 20%, #2d1a49, transparent 45%),
|
||||
radial-gradient(circle at 80% 0%, #551e8c, transparent 40%),
|
||||
radial-gradient(circle at 50% 80%, #150b28, transparent 30%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--gradient), #050109;
|
||||
color: #f7f1ff;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem 4rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
&--muted {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 3rem 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
&--home {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
gap: 2.75rem;
|
||||
|
||||
.hero__visual img {
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
|
||||
h1 {
|
||||
font-size: 2.2rem;
|
||||
line-height: 1.1;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
|
||||
&--centered {
|
||||
justify-content: center;
|
||||
margin: 2rem auto 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__visual {
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
max-width: 200px;
|
||||
width: 100%;
|
||||
filter: drop-shadow(0 25px 45px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
|
||||
&--large img {
|
||||
max-width: 270px;
|
||||
}
|
||||
}
|
||||
|
||||
&__auth {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 18px;
|
||||
padding: 1.75rem;
|
||||
margin-bottom: 1.75rem;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&--glass {
|
||||
background: var(--bg-glass);
|
||||
box-shadow: 0 20px 38px rgba(2, 0, 8, 0.45);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
color: var(--muted);
|
||||
margin-top: 0.35rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.35rem 0.9rem;
|
||||
background: rgba(178, 89, 255, 0.2);
|
||||
color: var(--accent);
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="file"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: 2px solid rgba(178, 89, 255, 0.5);
|
||||
border-color: rgba(178, 89, 255, 0.6);
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
color: var(--muted);
|
||||
|
||||
&__input {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.65rem 1.55rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(120deg, var(--accent), var(--accent-strong));
|
||||
color: #08010f;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 25px rgba(178, 89, 255, 0.35);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: linear-gradient(120deg, #ff4d6d, #ff89a5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--small {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
&--two {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.u-mt-1 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.95rem;
|
||||
margin: 1rem 0 2rem;
|
||||
max-width: 600px;
|
||||
|
||||
&--info {
|
||||
background: rgba(178, 89, 255, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: rgba(255, 77, 109, 0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: rgba(89, 255, 167, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&--stack > li {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.media-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.9rem 0;
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.player-current {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 16px;
|
||||
padding: 1.2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
&--empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
|
||||
&__card {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: var(--bg-card);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 15px 30px rgba(2, 0, 8, 0.45);
|
||||
}
|
||||
|
||||
.pill {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero,
|
||||
.hero--home {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero__content p {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.btn,
|
||||
input[type="text"],
|
||||
input[type="file"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue