Files
pmomusic/pmoapp/webapp/src/components/AudioCacheManager.vue

990 lines
23 KiB
Vue
Raw Normal View History

2025-10-18 14:35:19 +02:00
<template>
<div class="audio-cache-manager">
<div class="header">
<h2>🎵 Audio Cache Manager</h2>
<div class="stats">
<span>{{ tracks.length }} tracks</span>
<span v-if="totalHits > 0">{{ totalHits }} hits</span>
</div>
</div>
<!-- Formulaire d'ajout -->
<div class="add-form">
<h3> Add New Track</h3>
<form @submit.prevent="handleAddTrack">
<div class="form-group">
<input
v-model="newTrackUrl"
type="url"
placeholder="https://example.com/track.flac"
required
:disabled="isAdding"
/>
<input
v-model="newTrackCollection"
type="text"
placeholder="Collection (optional)"
:disabled="isAdding"
class="collection-input"
/>
<button type="submit" :disabled="isAdding || !newTrackUrl">
{{ isAdding ? "Adding..." : "Add Track" }}
</button>
</div>
<p v-if="addError" class="error">{{ addError }}</p>
<p v-if="addSuccess" class="success">{{ addSuccess }}</p>
</form>
</div>
<!-- Contrôles -->
<div class="controls">
<div class="sort-controls">
<label>Sort by:</label>
<select v-model="sortBy">
<option value="hits">Most Used</option>
<option value="last_used">Recently Used</option>
<option value="recent">Recently Added</option>
</select>
</div>
<div class="actions">
<button @click="refreshTracks" :disabled="isLoading">
{{ isLoading ? "Loading..." : "Refresh" }}
</button>
<button @click="handleConsolidate" :disabled="isConsolidating" class="btn-secondary">
{{ isConsolidating ? "Consolidating..." : "Consolidate" }}
</button>
<button @click="handlePurge" class="btn-danger" :disabled="isPurging">
{{ isPurging ? "Purging..." : "Purge All" }}
</button>
</div>
</div>
<!-- Liste des pistes -->
<div v-if="isLoading && tracks.length === 0" class="loading-state">
Loading tracks...
</div>
<div v-else-if="tracks.length === 0" class="empty-state">
No tracks in cache. Add one using the form above!
</div>
<div v-else class="track-grid">
<div
v-for="track in sortedTracks"
:key="track.pk"
class="track-card"
@click="selectedTrack = track"
>
<div class="track-icon">
2025-11-25 08:24:08 +01:00
<img
v-if="getTrackCoverUrl(track, 400)"
:src="getTrackCoverUrl(track, 400)"
:alt="`Cover for ${track.metadata?.title || 'Unknown'}`"
class="cover-image"
@error="handleCoverError(track.pk)"
/>
<div v-else class="music-icon">🎵</div>
2025-10-18 14:35:19 +02:00
<div class="track-overlay">
<span class="hits">{{ track.hits }} plays</span>
</div>
</div>
<div class="track-info">
<div class="track-title">
{{ track.metadata?.title || "Unknown Title" }}
</div>
<div class="track-artist">
{{ track.metadata?.artist || "Unknown Artist" }}
</div>
<div class="track-album" v-if="track.metadata?.album">
{{ track.metadata.album }}
</div>
<div class="pk">{{ track.pk }}</div>
<div class="meta">
2025-10-26 18:30:50 +01:00
<span v-if="durationMs(track) !== undefined">
{{ formatDuration(durationMs(track)!) }}
2025-10-18 14:35:19 +02:00
</span>
<span v-if="track.metadata?.sample_rate">
{{ formatSampleRate(track.metadata.sample_rate) }}
</span>
<span v-if="track.metadata?.bitrate">
{{ formatBitrate(track.metadata.bitrate) }}
</span>
<span v-if="conversionLabel(track)">
{{ conversionLabel(track) }}
</span>
2025-10-18 14:35:19 +02:00
</div>
<div class="collection" v-if="track.collection">
{{ track.collection }}
</div>
<div class="last-used" v-if="track.last_used">
Last used: {{ formatDate(track.last_used) }}
</div>
</div>
<div class="track-actions">
<button
@click.stop="playTrack(track.pk)"
class="btn-play"
title="Play"
>
</button>
<button
@click.stop="handleDeleteTrack(track.pk)"
class="btn-delete"
:disabled="deletingTracks.has(track.pk)"
title="Delete"
>
{{ deletingTracks.has(track.pk) ? "..." : "🗑️" }}
</button>
</div>
</div>
</div>
<!-- Modal de détails -->
<div v-if="selectedTrack" class="modal" @click="selectedTrack = null">
<div class="modal-content" @click.stop>
<button class="modal-close" @click="selectedTrack = null"></button>
<div class="modal-header">
2025-11-25 08:24:08 +01:00
<img
v-if="getTrackCoverUrl(selectedTrack, 200)"
:src="getTrackCoverUrl(selectedTrack, 200)"
:alt="`Cover for ${selectedTrack.metadata?.title || 'Unknown'}`"
class="modal-cover-image"
@error="handleCoverError(selectedTrack.pk)"
/>
<div v-else class="modal-icon">🎵</div>
2025-10-18 14:35:19 +02:00
<h3>Track Details</h3>
</div>
<div class="modal-info">
<div class="metadata-section" v-if="selectedTrack.metadata">
<h4>Metadata</h4>
<p><strong>Title:</strong> {{ selectedTrack.metadata.title || "Unknown" }}</p>
<p><strong>Artist:</strong> {{ selectedTrack.metadata.artist || "Unknown" }}</p>
<p v-if="selectedTrack.metadata.album"><strong>Album:</strong> {{ selectedTrack.metadata.album }}</p>
<p v-if="selectedTrack.metadata.year"><strong>Year:</strong> {{ selectedTrack.metadata.year }}</p>
<p v-if="selectedTrack.metadata.genre"><strong>Genre:</strong> {{ selectedTrack.metadata.genre }}</p>
<p v-if="selectedTrack.metadata.track_number"><strong>Track:</strong> {{ selectedTrack.metadata.track_number }}</p>
2025-10-26 18:30:50 +01:00
<p v-if="durationMs(selectedTrack) !== undefined">
<strong>Duration:</strong> {{ formatDuration(durationMs(selectedTrack)!) }}
</p>
2025-10-18 14:35:19 +02:00
<p v-if="selectedTrack.metadata.sample_rate"><strong>Sample Rate:</strong> {{ formatSampleRate(selectedTrack.metadata.sample_rate) }}</p>
<p v-if="selectedTrack.metadata.bitrate"><strong>Bitrate:</strong> {{ formatBitrate(selectedTrack.metadata.bitrate) }}</p>
<p v-if="selectedTrack.metadata.channels"><strong>Channels:</strong> {{ selectedTrack.metadata.channels }}</p>
<p v-if="conversionLabel(selectedTrack)"><strong>Conversion:</strong> {{ conversionLabel(selectedTrack) }}</p>
2025-10-18 14:35:19 +02:00
</div>
<div class="cache-section">
<h4>Cache Info</h4>
<p><strong>PK:</strong> {{ selectedTrack.pk }}</p>
2025-10-26 18:30:50 +01:00
<p v-if="resolveTrackOrigin(selectedTrack)">
<strong>Source URL:</strong>
<a :href="resolveTrackOrigin(selectedTrack)" target="_blank">{{ resolveTrackOrigin(selectedTrack) }}</a>
</p>
<p v-else><strong>Source URL:</strong> Unknown</p>
2025-10-18 14:35:19 +02:00
<p><strong>Hits:</strong> {{ selectedTrack.hits }}</p>
<p v-if="selectedTrack.collection"><strong>Collection:</strong> {{ selectedTrack.collection }}</p>
<p v-if="selectedTrack.last_used"><strong>Last Used:</strong> {{ formatDate(selectedTrack.last_used) }}</p>
</div>
<div class="modal-actions">
<button @click="playTrack(selectedTrack.pk)" class="btn-play">
Play
</button>
<button @click="downloadTrack(selectedTrack.pk)" class="btn-secondary">
Download
</button>
<button @click="copyTrackUrl(selectedTrack.pk)" class="btn-secondary">
📋 Copy URL
</button>
<button @click="handleDeleteTrack(selectedTrack.pk); selectedTrack = null" class="btn-danger">
🗑 Delete
</button>
</div>
</div>
</div>
</div>
<!-- Lecteur audio -->
<div v-if="isPlaying || audioError" class="audio-player-container">
<audio
ref="audioPlayer"
controls
v-if="!audioError"
@ended="handleAudioEnded"
@error="handleAudioError"
></audio>
<p v-if="audioError" class="audio-error">{{ audioError }}</p>
<button @click="stopTrack" class="btn-stop" title="Stop">
{{ audioError ? '✕ Close' : '⏹️ Stop' }}
</button>
</div>
2025-10-18 14:35:19 +02:00
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import type { AudioCacheEntry } from "../services/audioCache";
import {
listTracks,
addTrack,
deleteTrack,
purgeCache,
consolidateCache,
getTrackUrl,
getOriginalTrackUrl,
2025-10-26 18:30:50 +01:00
getOriginUrl,
getDurationMs,
2025-10-18 14:35:19 +02:00
formatDuration,
formatBitrate,
formatSampleRate,
2025-11-25 08:24:08 +01:00
getCoverUrl,
2025-10-18 14:35:19 +02:00
} from "../services/audioCache";
// --- États ---
const tracks = ref<AudioCacheEntry[]>([]);
const selectedTrack = ref<AudioCacheEntry | null>(null);
const isLoading = ref(false);
const sortBy = ref<"hits" | "last_used" | "recent">("hits");
const audioPlayer = ref<HTMLAudioElement | null>(null);
// Formulaire d'ajout
const newTrackUrl = ref("");
const newTrackCollection = ref("");
const isAdding = ref(false);
const addError = ref("");
const addSuccess = ref("");
// Contrôles
const isConsolidating = ref(false);
const isPurging = ref(false);
const deletingTracks = ref(new Set<string>());
// Lecteur audio
const isPlaying = ref(false);
const audioError = ref("");
2025-11-25 08:24:08 +01:00
// Gestion des erreurs de chargement des covers
const failedCovers = ref(new Set<string>());
2025-10-18 14:35:19 +02:00
// --- Computed ---
const totalHits = computed(() => tracks.value.reduce((sum, t) => sum + t.hits, 0));
const sortedTracks = computed(() => {
const arr = [...tracks.value];
switch (sortBy.value) {
case "hits":
return arr.sort((a, b) => b.hits - a.hits);
case "last_used":
return arr.sort((a, b) => {
if (!a.last_used) return 1;
if (!b.last_used) return -1;
return new Date(b.last_used).getTime() - new Date(a.last_used).getTime();
});
case "recent":
return arr.reverse();
default:
return arr;
}
});
// --- Fonctions ---
async function refreshTracks() {
isLoading.value = true;
try {
tracks.value = await listTracks();
} finally {
isLoading.value = false;
}
}
async function handleAddTrack() {
if (!newTrackUrl.value) return;
isAdding.value = true;
addError.value = "";
addSuccess.value = "";
try {
const result = await addTrack(
newTrackUrl.value,
newTrackCollection.value || undefined
);
addSuccess.value = `Track added! PK: ${result.pk}`;
newTrackUrl.value = "";
newTrackCollection.value = "";
await refreshTracks();
} catch (e: any) {
addError.value = e.message ?? "Failed to add track";
} finally {
isAdding.value = false;
setTimeout(() => (addSuccess.value = ""), 3000);
}
}
async function handleDeleteTrack(pk: string) {
if (!confirm(`Delete track ${pk}?`)) return;
deletingTracks.value.add(pk);
try {
await deleteTrack(pk);
await refreshTracks();
} finally {
deletingTracks.value.delete(pk);
}
}
async function handlePurge() {
if (!confirm("⚠️ Delete ALL tracks?")) return;
isPurging.value = true;
try {
await purgeCache();
await refreshTracks();
} finally {
isPurging.value = false;
}
}
async function handleConsolidate() {
if (!confirm("Consolidate cache? This will re-download missing tracks.")) return;
isConsolidating.value = true;
try {
await consolidateCache();
await refreshTracks();
} finally {
isConsolidating.value = false;
}
}
function playTrack(pk: string) {
audioError.value = "";
isPlaying.value = true;
// Attendre que le DOM soit mis à jour (car le lecteur audio est dans un v-if)
setTimeout(() => {
if (audioPlayer.value) {
const url = getTrackUrl(pk);
audioPlayer.value.src = url;
audioPlayer.value.play().catch((error) => {
console.error("Failed to play audio:", error);
audioError.value = `Cannot play audio: ${error.message}. Your browser may not support FLAC format.`;
isPlaying.value = false;
});
}
}, 100);
}
function stopTrack() {
2025-10-18 14:35:19 +02:00
if (audioPlayer.value) {
audioPlayer.value.pause();
audioPlayer.value.currentTime = 0;
audioPlayer.value.src = "";
}
isPlaying.value = false;
audioError.value = "";
}
function handleAudioEnded() {
isPlaying.value = false;
audioError.value = "";
}
function handleAudioError() {
const audio = audioPlayer.value;
if (audio?.error) {
let message = "Audio playback error: ";
switch (audio.error.code) {
case 1:
message += "Loading aborted";
break;
case 2:
message += "Network error";
break;
case 3:
message += "Format not supported";
break;
case 4:
message += "Source not found";
break;
default:
message += "Unknown error";
}
console.error('Audio player error:', message, 'code:', audio.error.code);
audioError.value = message;
isPlaying.value = false;
2025-10-18 14:35:19 +02:00
}
}
function downloadTrack(pk: string) {
window.open(getOriginalTrackUrl(pk), "_blank");
}
function copyTrackUrl(pk: string) {
navigator.clipboard.writeText(window.location.origin + getTrackUrl(pk));
alert("✅ URL copied!");
}
2025-10-26 18:30:50 +01:00
function resolveTrackOrigin(track: AudioCacheEntry | null): string | undefined {
return track ? getOriginUrl(track) : undefined;
}
function durationMs(track: AudioCacheEntry | null): number | undefined {
return track ? getDurationMs(track.metadata) : undefined;
}
2025-10-18 14:35:19 +02:00
function formatDate(dateString: string) {
const d = new Date(dateString);
const diff = Date.now() - d.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return "Today";
if (days === 1) return "Yesterday";
if (days < 7) return `${days} days ago`;
return d.toLocaleDateString();
}
function formatConversion(
conversion?: { mode?: string; input_codec?: string; details?: string } | null
): string | undefined {
if (!conversion || !conversion.mode) return undefined;
const modeLower = conversion.mode.toLowerCase();
const modeLabel =
modeLower === "passthrough"
? "Passthrough"
: modeLower === "transcode"
? "Transcoded"
: conversion.mode.charAt(0).toUpperCase() + conversion.mode.slice(1);
if (conversion.input_codec) {
const codec = conversion.input_codec.toUpperCase();
if (modeLower === "passthrough") {
return `${modeLabel} (${codec})`;
}
return `${modeLabel} (${codec} → FLAC)`;
}
if (conversion.details) {
return `${modeLabel} ${conversion.details}`;
}
return modeLabel;
}
function conversionLabel(track: AudioCacheEntry | null): string | undefined {
return formatConversion(track?.metadata?.conversion ?? undefined);
}
2025-11-25 08:24:08 +01:00
function getTrackCoverUrl(track: AudioCacheEntry | null, size?: number): string | undefined {
if (!track || failedCovers.value.has(track.pk)) return undefined;
return getCoverUrl(track.metadata, size);
}
function handleCoverError(pk: string) {
failedCovers.value.add(pk);
}
onMounted(() => {
refreshTracks();
});
2025-10-18 14:35:19 +02:00
</script>
<style scoped>
.audio-cache-manager {
padding: 1rem;
width: 100%;
max-width: 100%;
margin: 0;
box-sizing: border-box;
}
@media (min-width: 1400px) {
.audio-cache-manager {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
}
@media (max-width: 768px) {
.audio-cache-manager {
padding: 0.5rem;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #444;
}
.header h2 {
margin: 0;
color: #61dafb;
}
.stats {
display: flex;
gap: 1rem;
font-size: 0.9rem;
color: #999;
}
/* Formulaire d'ajout */
.add-form {
background: #2a2a2a;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.add-form h3 {
margin-top: 0;
color: #61dafb;
}
.form-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.form-group input {
padding: 0.75rem;
border: 1px solid #444;
border-radius: 4px;
background: #1a1a1a;
color: #fff;
font-size: 1rem;
}
.form-group input[type="url"] {
flex: 2;
min-width: 250px;
}
.collection-input {
flex: 1;
min-width: 150px;
}
.form-group button {
padding: 0.75rem 1.5rem;
background: #61dafb;
color: #000;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: all 0.2s;
}
.form-group button:hover:not(:disabled) {
background: #4fa8c5;
}
.form-group button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
color: #ff6b6b;
margin-top: 0.5rem;
}
.success {
color: #51cf66;
margin-top: 0.5rem;
}
/* Contrôles */
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding: 1rem;
background: #2a2a2a;
border-radius: 8px;
flex-wrap: wrap;
gap: 1rem;
}
.sort-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.sort-controls label {
color: #999;
}
.sort-controls select {
padding: 0.5rem;
border: 1px solid #444;
border-radius: 4px;
background: #1a1a1a;
color: #fff;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
button:not(.btn-danger):not(.btn-secondary):not(.btn-play):not(.btn-delete) {
background: #61dafb;
color: #000;
}
button:not(.btn-danger):not(.btn-secondary):not(.btn-play):not(.btn-delete):hover:not(:disabled) {
background: #4fa8c5;
}
.btn-secondary {
background: #555;
color: #fff;
}
.btn-secondary:hover:not(:disabled) {
background: #666;
}
.btn-danger {
background: #ff6b6b;
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #ee5a52;
}
.btn-play {
background: #51cf66;
color: #fff;
}
.btn-play:hover:not(:disabled) {
background: #40c057;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* États */
.loading-state,
.empty-state {
text-align: center;
padding: 3rem;
color: #999;
font-size: 1.2rem;
}
/* Grille de pistes */
.track-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
}
.track-card {
background: #2a2a2a;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
}
.track-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
}
.track-icon {
position: relative;
width: 100%;
padding-top: 56.25%; /* Ratio 16:9 */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
overflow: hidden;
}
2025-11-25 08:24:08 +01:00
.cover-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
2025-10-18 14:35:19 +02:00
.music-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 4rem;
opacity: 0.3;
}
.track-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
padding: 0.5rem;
color: #fff;
}
.hits {
font-size: 0.9rem;
}
.track-info {
padding: 1rem;
flex: 1;
}
.track-title {
font-size: 1.1rem;
font-weight: bold;
color: #61dafb;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-artist {
color: #ccc;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-album {
color: #999;
font-size: 0.9rem;
margin-bottom: 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pk {
font-family: monospace;
color: #777;
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
.meta {
display: flex;
gap: 0.75rem;
font-size: 0.85rem;
color: #999;
flex-wrap: wrap;
}
.collection {
color: #888;
font-size: 0.85rem;
margin-top: 0.5rem;
font-style: italic;
}
.last-used {
color: #777;
font-size: 0.8rem;
margin-top: 0.5rem;
}
.track-actions {
padding: 0 1rem 1rem;
display: flex;
gap: 0.5rem;
}
.track-actions button {
flex: 1;
padding: 0.5rem;
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 2rem;
}
.modal-content {
background: #2a2a2a;
border-radius: 12px;
max-width: 700px;
max-height: 90vh;
overflow: auto;
position: relative;
width: 100%;
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
font-size: 1.2rem;
z-index: 1;
}
.modal-close:hover {
background: rgba(0, 0, 0, 0.8);
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #444;
display: flex;
align-items: center;
gap: 1rem;
}
2025-11-25 08:24:08 +01:00
.modal-cover-image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
2025-10-18 14:35:19 +02:00
.modal-icon {
font-size: 3rem;
}
.modal-header h3 {
margin: 0;
color: #61dafb;
}
.modal-info {
padding: 1.5rem;
}
.metadata-section,
.cache-section {
margin-bottom: 1.5rem;
}
.metadata-section h4,
.cache-section h4 {
color: #61dafb;
margin-top: 0;
margin-bottom: 1rem;
}
.modal-info p {
margin: 0.5rem 0;
color: #ccc;
}
.modal-info a {
color: #61dafb;
text-decoration: none;
word-break: break-all;
}
.modal-info a:hover {
text-decoration: underline;
}
.modal-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.modal-actions button {
flex: 1;
min-width: 120px;
}
/* Lecteur audio */
.audio-player-container {
position: fixed;
bottom: 2rem;
right: 2rem;
background: #2a2a2a;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
display: flex;
gap: 1rem;
align-items: center;
z-index: 1001;
}
.audio-player-container audio {
max-width: 400px;
}
.audio-error {
color: #ff6b6b;
margin: 0;
padding: 0.5rem;
background: rgba(255, 107, 107, 0.1);
border-radius: 4px;
max-width: 400px;
}
.btn-stop {
background: #ff6b6b;
color: #fff;
padding: 0.75rem 1.5rem;
white-space: nowrap;
}
.btn-stop:hover:not(:disabled) {
background: #ee5a52;
}
@media (max-width: 768px) {
.audio-player-container {
bottom: 1rem;
right: 1rem;
left: 1rem;
flex-direction: column;
}
.audio-player-container audio {
max-width: 100%;
width: 100%;
}
}
2025-10-18 14:35:19 +02:00
</style>