Compare commits
25 Commits
4b7121271a
...
v0.1.6.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1991cbd93f | ||
|
|
c28c9fe8f0 | ||
|
|
803713dea7 | ||
|
|
0e6eba64d9 | ||
|
|
576b486caf | ||
|
|
d8f69631b5 | ||
|
|
dbcdaf9278 | ||
|
|
2e93d09236 | ||
|
|
a1fe62f132 | ||
|
|
e49c6acc99 | ||
|
|
96cc9db7d6 | ||
|
|
ebc482dc87 | ||
|
|
88dd86c344 | ||
|
|
623e8b9b82 | ||
|
|
286ac2d28a | ||
|
|
c02d3df7ed | ||
|
|
702f47b7e5 | ||
|
|
86f3349f80 | ||
|
|
bdb74fb462 | ||
|
|
66c0071257 | ||
|
|
76f14087fd | ||
|
|
b1ab5bd633 | ||
|
|
51c62e7763 | ||
|
|
de6eadfe62 | ||
|
|
b033c3a1bc |
1
.cursor/commands/bump.md
Normal file
1
.cursor/commands/bump.md
Normal file
@@ -0,0 +1 @@
|
||||
teste den build (npm run build), anschließend commit, dann bump zum nächsten patchlevel, git tag und sync
|
||||
19
README.md
19
README.md
@@ -57,6 +57,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- **Global-Kuratoren:** Optionale globale Kuratoren, die für alle Rätsel zuständig sind.
|
||||
- **Kurator-Dashboard:** Eigene Dashboard-Seite (`/curator` oder `/de/curator`, `/en/curator`) für Kuratoren.
|
||||
- **Song-Verwaltung:** Kuratoren können Songs hochladen, bearbeiten und Genres/Specials zuweisen.
|
||||
- **Curate Specials:** Kuratoren können in einem eigenen Bereich („Curate Specials“) die Startzeiten der Songs in ihren zugewiesenen Specials über den Waveform-Editor einstellen – streng begrenzt auf ihre eigenen Specials.
|
||||
- **Batch-Edit:** Mehrere Titel gleichzeitig bearbeiten (Genre/Special Toggle, Artist ändern, Exclude Global Flag setzen).
|
||||
- **Kommentar-Verwaltung:** Kuratoren können Spieler-Kommentare zu ihren Rätseln einsehen, als gelesen markieren und archivieren.
|
||||
- **Spieler-Kommentare:**
|
||||
@@ -195,14 +196,16 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
||||
- **Optional:** Trage einen Kurator ein.
|
||||
- Weise Songs dem Special zu (über die Song-Bibliothek).
|
||||
- Klicke auf "Curate" neben dem Special.
|
||||
- Nutze den Waveform-Editor um den perfekten Ausschnitt zu wählen:
|
||||
- **Klicken:** Positioniert die Selektion
|
||||
- **Hovern:** Zeigt Vorschau der neuen Position
|
||||
- **Zoom:** 🔍+ / 🔍− Buttons für detaillierte Ansicht
|
||||
- **Pan:** ← / → Buttons zum Verschieben der Ansicht
|
||||
- **Segment-Playback:** Teste einzelne Puzzle-Abschnitte
|
||||
- **Save:** Speichere Änderungen mit dem grünen Button
|
||||
- Die eigentliche Kuratierung (Auswahl des Ausschnitts) findet im **Kuratoren-Dashboard** statt:
|
||||
- Logge dich als Kurator ein und gehe zu `/de/curator` oder `/en/curator`.
|
||||
- Klicke im Dashboard auf **„Curate Specials“**, um eine Liste deiner zugewiesenen Specials zu sehen.
|
||||
- Öffne ein Special und nutze dort den Waveform-Editor, um den perfekten Ausschnitt zu wählen:
|
||||
- **Klicken:** Positioniert die Selektion
|
||||
- **Hovern:** Zeigt Vorschau der neuen Position
|
||||
- **Zoom:** 🔍+ / 🔍− Buttons für detaillierte Ansicht
|
||||
- **Pan:** ← / → Buttons zum Verschieben der Ansicht
|
||||
- **Segment-Playback:** Teste einzelne Puzzle-Abschnitte
|
||||
- **Save:** Speichere Änderungen mit dem grünen Button
|
||||
- Die Spieler hören dann nur den kuratierten Ausschnitt.
|
||||
- Auf der Startseite werden zukünftige Specials unter "Coming soon" angezeigt (mit Datum und Kurator).
|
||||
|
||||
|
||||
@@ -20,11 +20,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ rewrittenMessage: message });
|
||||
}
|
||||
|
||||
const prompt = `Rewrite the following message to express the COMPLETE OPPOSITE meaning and opinion.
|
||||
If the message is negative or critical, rewrite it to be overwhelmingly positive, praising, and appreciative.
|
||||
If the message is positive, rewrite it to be critical or negative.
|
||||
Maintain the original language (German or English).
|
||||
Return ONLY the rewritten message text, nothing else.
|
||||
const prompt = `You are a content moderation assistant. Analyze the following message and determine if it is truly inappropriate, unfriendly, sexist, or offensive.
|
||||
|
||||
Rules:
|
||||
- ONLY rewrite the message if it is genuinely unfriendly, sexist, inappropriate, or offensive
|
||||
- If the message is polite, constructive, or even just neutral/critical feedback, return it UNCHANGED
|
||||
- If the message needs rewriting, rewrite it to express the COMPLETE OPPOSITE meaning - make it positive, respectful, and appreciative
|
||||
- Maintain the original language (German or English)
|
||||
- Return ONLY the message text (either unchanged original or rewritten version), nothing else
|
||||
|
||||
Message: "${message}"`;
|
||||
|
||||
@@ -58,8 +61,39 @@ Message: "${message}"`;
|
||||
const data = await response.json();
|
||||
let rewrittenMessage = data.choices?.[0]?.message?.content?.trim() || message;
|
||||
|
||||
// Add suffix
|
||||
rewrittenMessage += " (autocorrected by Polite-Bot)";
|
||||
// Remove any explanatory comments in parentheses that the AI might add
|
||||
// e.g., "(This message is a friendly, positive comment expressing appreciation. No rewriting is necessary.)"
|
||||
rewrittenMessage = rewrittenMessage.replace(/\s*\([^)]*\)\s*/g, '').trim();
|
||||
|
||||
// Remove surrounding quotes if present (AI sometimes adds quotes)
|
||||
// Handle both single and double quotes, and multiple layers of quotes
|
||||
rewrittenMessage = rewrittenMessage.replace(/^["']+|["']+$/g, '').trim();
|
||||
|
||||
// Normalize both messages for comparison (remove extra whitespace, normalize quotes, case-insensitive)
|
||||
const normalizeForComparison = (text: string): string => {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/["']/g, '') // Remove all quotes for comparison
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.toLowerCase()
|
||||
.replace(/[.,!?;:]\s*$/, ''); // Remove trailing punctuation for comparison
|
||||
};
|
||||
|
||||
const originalTrimmed = message.trim();
|
||||
const rewrittenTrimmed = rewrittenMessage.trim();
|
||||
const originalNormalized = normalizeForComparison(originalTrimmed);
|
||||
const rewrittenNormalized = normalizeForComparison(rewrittenTrimmed);
|
||||
|
||||
// Check if message was actually changed (content-wise, not just formatting)
|
||||
// Only consider it changed if the normalized content is different
|
||||
const wasChanged = originalNormalized !== rewrittenNormalized;
|
||||
|
||||
if (wasChanged) {
|
||||
rewrittenMessage = rewrittenTrimmed + " (autocorrected by Polite-Bot)";
|
||||
} else {
|
||||
// Return original message if not changed (without suffix)
|
||||
rewrittenMessage = originalTrimmed;
|
||||
}
|
||||
|
||||
return NextResponse.json({ rewrittenMessage });
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// Validate and extract metadata from file
|
||||
let metadata;
|
||||
let releaseYear: number | null = null;
|
||||
let validationInfo = {
|
||||
isValid: true,
|
||||
hasCover: false,
|
||||
@@ -244,6 +245,11 @@ export async function POST(request: Request) {
|
||||
artist = metadata.common.albumartist;
|
||||
}
|
||||
|
||||
// Try to extract release year from tags (preferred over external APIs)
|
||||
if (typeof metadata.common.year === 'number') {
|
||||
releaseYear = metadata.common.year;
|
||||
}
|
||||
|
||||
// Validation info
|
||||
validationInfo.hasCover = !!metadata.common.picture?.[0];
|
||||
validationInfo.format = metadata.format.container || 'unknown';
|
||||
@@ -338,17 +344,19 @@ export async function POST(request: Request) {
|
||||
console.error('Failed to extract cover image:', e);
|
||||
}
|
||||
|
||||
// Fetch release year from iTunes
|
||||
let releaseYear = null;
|
||||
try {
|
||||
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
|
||||
releaseYear = await getReleaseYearFromItunes(artist, title);
|
||||
// Fetch release year from iTunes only if not already present from tags
|
||||
if (releaseYear == null) {
|
||||
try {
|
||||
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
|
||||
const fetchedYear = await getReleaseYearFromItunes(artist, title);
|
||||
|
||||
if (releaseYear) {
|
||||
console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`);
|
||||
if (fetchedYear) {
|
||||
releaseYear = fetchedYear;
|
||||
console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch release year:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch release year:', e);
|
||||
}
|
||||
|
||||
const song = await prisma.song.create({
|
||||
|
||||
@@ -107,6 +107,7 @@ export default function CuratorPageClient() {
|
||||
// Upload state (analog zum Admin-Upload, aber vereinfacht)
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [uploadGenreIds, setUploadGenreIds] = useState<number[]>([]);
|
||||
const [uploadSpecialIds, setUploadSpecialIds] = useState<number[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number }>({
|
||||
@@ -534,6 +535,12 @@ export default function CuratorPageClient() {
|
||||
);
|
||||
};
|
||||
|
||||
const toggleUploadSpecial = (specialId: number) => {
|
||||
setUploadSpecialIds(prev =>
|
||||
prev.includes(specialId) ? prev.filter(id => id !== specialId) : [...prev, specialId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(e.target.files || []);
|
||||
if (selected.length === 0) return;
|
||||
@@ -636,8 +643,8 @@ export default function CuratorPageClient() {
|
||||
setFiles([]);
|
||||
setIsUploading(false);
|
||||
|
||||
// Genres den erfolgreich hochgeladenen Songs zuweisen
|
||||
if (uploadGenreIds.length > 0) {
|
||||
// Genres/Specials den erfolgreich hochgeladenen Songs zuweisen
|
||||
if (uploadGenreIds.length > 0 || uploadSpecialIds.length > 0) {
|
||||
const successfulUploads = results.filter(r => r.success && r.song);
|
||||
for (const result of successfulUploads) {
|
||||
try {
|
||||
@@ -649,12 +656,13 @@ export default function CuratorPageClient() {
|
||||
title: result.song.title,
|
||||
artist: result.song.artist,
|
||||
releaseYear: result.song.releaseYear,
|
||||
genreIds: uploadGenreIds,
|
||||
genreIds: uploadGenreIds.length > 0 ? uploadGenreIds : undefined,
|
||||
specialIds: uploadSpecialIds.length > 0 ? uploadSpecialIds : undefined,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// Fehler beim Genre-Assigning werden nur geloggt, nicht abgebrochen
|
||||
console.error(`Failed to assign genres to ${result.song.title}`);
|
||||
// Fehler beim Zuweisen werden nur geloggt, nicht abgebrochen
|
||||
console.error(`Failed to assign genres/specials to ${result.song.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1149,44 +1157,87 @@ export default function CuratorPageClient() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('assignGenresLabel')}</div>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipGenreAssignmentShort')}
|
||||
longText={tHelp('tooltipGenreAssignmentLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{genres
|
||||
.filter(g => curatorInfo?.genreIds?.includes(g.id))
|
||||
.map(genre => (
|
||||
<label
|
||||
key={genre.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '999px',
|
||||
background: uploadGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uploadGenreIds.includes(genre.id)}
|
||||
onChange={() => toggleUploadGenre(genre.id)}
|
||||
/>
|
||||
{typeof genre.name === 'string' ? genre.name : genre.name?.de ?? genre.name?.en}
|
||||
</label>
|
||||
))}
|
||||
{curatorInfo && curatorInfo.genreIds.length === 0 && (
|
||||
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
|
||||
{t('noAssignedGenres')}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('assignGenresLabel')}</div>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipGenreAssignmentShort')}
|
||||
longText={tHelp('tooltipGenreAssignmentLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{genres
|
||||
.filter(g => curatorInfo?.genreIds?.includes(g.id))
|
||||
.map(genre => (
|
||||
<label
|
||||
key={genre.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '999px',
|
||||
background: uploadGenreIds.includes(genre.id) ? '#e5f3ff' : '#f3f4f6',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uploadGenreIds.includes(genre.id)}
|
||||
onChange={() => toggleUploadGenre(genre.id)}
|
||||
/>
|
||||
{typeof genre.name === 'string' ? genre.name : genre.name?.de ?? genre.name?.en}
|
||||
</label>
|
||||
))}
|
||||
{curatorInfo && curatorInfo.genreIds.length === 0 && (
|
||||
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
|
||||
{t('noAssignedGenres')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem', marginTop: '0.5rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('assignSpecialsLabel')}</div>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipSpecialAssignmentShort')}
|
||||
longText={tHelp('tooltipSpecialAssignmentLong')}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{specials
|
||||
.filter(s => curatorInfo?.specialIds?.includes(s.id))
|
||||
.map(special => (
|
||||
<label
|
||||
key={special.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '999px',
|
||||
background: uploadSpecialIds.includes(special.id) ? '#fef3c7' : '#f3f4f6',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uploadSpecialIds.includes(special.id)}
|
||||
onChange={() => toggleUploadSpecial(special.id)}
|
||||
/>
|
||||
{typeof special.name === 'string'
|
||||
? special.name
|
||||
: special.name?.de ?? special.name?.en}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -95,6 +95,28 @@ export default function CuratorHelpClient() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Specials kuratieren */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
{t('curateSpecialsHelpTitle')}
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||
<p style={{ marginBottom: '1rem' }}>{t('curateSpecialsHelpIntro')}</p>
|
||||
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>
|
||||
{t('curateSpecialsHelpStepsTitle')}
|
||||
</h3>
|
||||
<ol style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep1')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep2')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep3')}</li>
|
||||
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep4')}</li>
|
||||
</ol>
|
||||
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#fef3c7', borderRadius: '0.375rem', border: '1px solid #fbbf24' }}>
|
||||
<strong>{t('note')}:</strong> {t('curateSpecialsPermissionsNote')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Kommentar-Verwaltung */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
|
||||
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
|
||||
import HelpTooltip from '@/components/HelpTooltip';
|
||||
|
||||
export default function CuratorSpecialEditorPage() {
|
||||
const params = useParams();
|
||||
@@ -14,6 +17,7 @@ export default function CuratorSpecialEditorPage() {
|
||||
const intlLocale = useLocale() as 'de' | 'en';
|
||||
const locale: 'de' | 'en' = urlLocale === 'de' || urlLocale === 'en' ? urlLocale : intlLocale;
|
||||
const t = useTranslations('Curator');
|
||||
const tHelp = useTranslations('CuratorHelp');
|
||||
|
||||
const specialId = params?.id as string;
|
||||
|
||||
@@ -117,20 +121,49 @@ export default function CuratorSpecialEditorPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<CurateSpecialEditor
|
||||
special={special}
|
||||
locale={locale}
|
||||
onBack={() => router.push(`/${locale}/curator/specials`)}
|
||||
onSaveStartTime={handleSaveStartTime}
|
||||
backLabel={t('backToCuratorSpecials')}
|
||||
headerPrefix={t('curateSpecialHeaderPrefix')}
|
||||
noSongsHint={t('curateSpecialNoSongs')}
|
||||
noSongsSubHint={t('curateSpecialNoSongsSub')}
|
||||
instructionsText={t('curateSpecialInstructions')}
|
||||
savingLabel={t('saving')}
|
||||
saveChangesLabel={t('saveChanges')}
|
||||
savedLabel={t('saved')}
|
||||
/>
|
||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold' }}>
|
||||
{t('curateSpecialHeaderPrefix')}
|
||||
</h1>
|
||||
<HelpTooltip
|
||||
shortText={tHelp('tooltipCurateSpecialEditorShort')}
|
||||
longText={tHelp('tooltipCurateSpecialEditorLong')}
|
||||
position="bottom"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/${locale}/curator/specials`)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{t('backToCuratorSpecials')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CurateSpecialEditor
|
||||
special={special}
|
||||
locale={locale}
|
||||
onBack={() => router.push(`/${locale}/curator/specials`)}
|
||||
onSaveStartTime={handleSaveStartTime}
|
||||
backLabel={t('backToCuratorSpecials')}
|
||||
headerPrefix={t('curateSpecialHeaderPrefix')}
|
||||
noSongsHint={t('curateSpecialNoSongs')}
|
||||
noSongsSubHint={t('curateSpecialNoSongsSub')}
|
||||
instructionsText={t('curateSpecialInstructions')}
|
||||
savingLabel={t('saving')}
|
||||
saveChangesLabel={t('saveChanges')}
|
||||
savedLabel={t('saved')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,152 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Link } from '@/lib/navigation';
|
||||
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
|
||||
// Root /curator/specials route without locale:
|
||||
// redirect users to the default English locale version.
|
||||
|
||||
type LocalizedString = string | { de: string; en: string };
|
||||
|
||||
interface CuratorSpecialSummary {
|
||||
id: number;
|
||||
name: LocalizedString;
|
||||
songCount: number;
|
||||
}
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function CuratorSpecialsPage() {
|
||||
const t = useTranslations('Curator');
|
||||
const locale = useLocale();
|
||||
const [specials, setSpecials] = useState<CuratorSpecialSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSpecials = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/curator/specials', {
|
||||
headers: getCuratorAuthHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSpecials(data);
|
||||
} else if (res.status === 403) {
|
||||
setError(t('noSpecialPermissions'));
|
||||
} else {
|
||||
setError('Failed to load specials');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to load specials');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSpecials();
|
||||
}, [t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p>{t('loadingData')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (specials.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p>{t('noSpecialsInScope')}</p>
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<Link
|
||||
href="/curator"
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
textDecoration: 'none',
|
||||
color: '#111827',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{t('backToDashboard')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resolveLocalized = (value: LocalizedString, locale: string): string => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
const loc = locale === 'de' || locale === 'en' ? locale : 'en';
|
||||
return value[loc] ?? value.en ?? value.de;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||
{t('curateSpecialsTitle')}
|
||||
</h1>
|
||||
<Link
|
||||
href="/curator"
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
textDecoration: 'none',
|
||||
color: '#111827',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{t('backToDashboard')}
|
||||
</Link>
|
||||
</div>
|
||||
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>
|
||||
{t('curateSpecialsDescription')}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||
{specials.map(special => (
|
||||
<Link
|
||||
key={special.id}
|
||||
href={`/curator/specials/${special.id}`}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: 'white',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: '0.25rem' }}>
|
||||
{resolveLocalized(special.name, String(locale))}
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.875rem', color: '#6b7280' }}>
|
||||
{t('curateSpecialSongCount', { count: special.songCount })}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem', textAlign: 'right', fontSize: '0.875rem', color: '#4f46e5' }}>
|
||||
{t('curateSpecialOpen')}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
redirect('/en/curator/specials');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const t = useTranslations('Game');
|
||||
const locale = useLocale();
|
||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||
const [hasWon, setHasWon] = useState(false);
|
||||
const [hasLost, setHasLost] = useState(false);
|
||||
const [hasWon, setHasWon] = useState(gameState?.isSolved ?? false);
|
||||
const [hasLost, setHasLost] = useState(gameState?.isFailed ?? false);
|
||||
const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
|
||||
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
|
||||
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
||||
@@ -67,6 +67,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const [commentError, setCommentError] = useState<string | null>(null);
|
||||
const [commentCollapsed, setCommentCollapsed] = useState(true);
|
||||
const [rewrittenMessage, setRewrittenMessage] = useState<string | null>(null);
|
||||
const [commentAIConsent, setCommentAIConsent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateCountdown = () => {
|
||||
@@ -87,12 +88,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameState && dailyPuzzle) {
|
||||
if (gameState) {
|
||||
setHasWon(gameState.isSolved);
|
||||
setHasLost(gameState.isFailed);
|
||||
|
||||
// Show year modal if won but year not guessed yet and release year is available
|
||||
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle.releaseYear) {
|
||||
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle?.releaseYear) {
|
||||
setShowYearModal(true);
|
||||
}
|
||||
}
|
||||
@@ -317,7 +318,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
};
|
||||
|
||||
const handleCommentSubmit = async () => {
|
||||
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle) {
|
||||
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle || !commentAIConsent) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -343,9 +344,16 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const rewriteData = await rewriteResponse.json();
|
||||
if (rewriteData.rewrittenMessage) {
|
||||
finalMessage = rewriteData.rewrittenMessage;
|
||||
// If message was changed significantly (simple check), show it
|
||||
if (finalMessage !== commentText.trim()) {
|
||||
setRewrittenMessage(finalMessage);
|
||||
// Only show rewritten message if it was actually changed
|
||||
// The API adds "(autocorrected by Polite-Bot)" suffix only if message was changed
|
||||
const wasChanged = finalMessage.includes('(autocorrected by Polite-Bot)');
|
||||
if (wasChanged) {
|
||||
// Remove the suffix for display
|
||||
const displayMessage = finalMessage.replace(/\s*\(autocorrected by Polite-Bot\)\s*/g, '').trim();
|
||||
setRewrittenMessage(displayMessage);
|
||||
} else {
|
||||
// Ensure rewrittenMessage is not set if message wasn't changed
|
||||
setRewrittenMessage(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,11 +424,22 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
||||
|
||||
// Use current domain from window.location to support both hoerdle.de and hördle.de
|
||||
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
||||
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
|
||||
|
||||
// For users on hördle.de, use Punycode domain (xn--hrdle-jua.de) in share message
|
||||
// to avoid rendering issues with Unicode domains
|
||||
let currentHost = rawHost;
|
||||
if (rawHost === 'hördle.de' || rawHost === 'xn--hrdle-jua.de') {
|
||||
currentHost = 'xn--hrdle-jua.de';
|
||||
}
|
||||
|
||||
// OLD CODE (commented out - may be needed again in the future):
|
||||
// Use current domain from window.location to support both hoerdle.de and hördle.de,
|
||||
// but always share the pretty Unicode-Domain "hördle.de" instead of the Punycode variant.
|
||||
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
||||
const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
|
||||
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
|
||||
// const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
|
||||
|
||||
let shareUrl = `${protocol}//${currentHost}`;
|
||||
// Add locale prefix if not default (en)
|
||||
if (locale !== 'en') {
|
||||
@@ -676,14 +695,26 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', fontSize: '0.85rem', color: 'var(--foreground)', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={commentAIConsent}
|
||||
onChange={(e) => setCommentAIConsent(e.target.checked)}
|
||||
disabled={commentSending || commentSent}
|
||||
style={{ marginTop: '0.2rem', cursor: (commentSending || commentSent) ? 'not-allowed' : 'pointer' }}
|
||||
/>
|
||||
<span>{t('commentAIConsent')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCommentSubmit}
|
||||
disabled={!commentText.trim() || commentSending || commentSent}
|
||||
disabled={!commentText.trim() || commentSending || commentSent || !commentAIConsent}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
width: '100%',
|
||||
opacity: (!commentText.trim() || commentSending || commentSent) ? 0.5 : 1,
|
||||
cursor: (!commentText.trim() || commentSending || commentSent) ? 'not-allowed' : 'pointer'
|
||||
opacity: (!commentText.trim() || commentSending || commentSent || !commentAIConsent) ? 0.5 : 1,
|
||||
cursor: (!commentText.trim() || commentSending || commentSent || !commentAIConsent) ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{commentSending ? t('sending') : t('sendComment')}
|
||||
@@ -695,14 +726,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
{commentSent && (
|
||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: rewrittenMessage ? '0.5rem' : 0 }}>
|
||||
{t('commentSent')}
|
||||
</p>
|
||||
{rewrittenMessage && (
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', textAlign: 'center' }}>
|
||||
<p style={{ marginBottom: '0.25rem' }}>{t('commentRewritten')}</p>
|
||||
<p style={{ fontStyle: 'italic' }}>"{rewrittenMessage}"</p>
|
||||
</div>
|
||||
{rewrittenMessage ? (
|
||||
<>
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: '0.5rem' }}>
|
||||
{t('commentSent')}
|
||||
</p>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', textAlign: 'center' }}>
|
||||
<p style={{ marginBottom: '0.25rem' }}>{t('commentRewritten')}</p>
|
||||
<p style={{ fontStyle: 'italic' }}>"{rewrittenMessage}"</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: 0 }}>
|
||||
{t('commentThankYou')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
"sendCommentCollapsed": "Nachricht an Kurator senden",
|
||||
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres... Bitte bleibe freundlich und höflich.",
|
||||
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
|
||||
"commentAIConsent": "Ich bin damit einverstanden, dass diese Nachricht von einer KI verarbeitet wird, um unfreundliche Nachrichten zu filtern.",
|
||||
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
|
||||
"commentThankYou": "Vielen Dank für dein Feedback!",
|
||||
"commentRewritten": "Deine Nachricht wurde automatisch freundlicher formuliert:",
|
||||
"commentError": "Fehler beim Senden der Nachricht",
|
||||
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
|
||||
@@ -203,6 +205,7 @@
|
||||
"selectedFilesTitle": "Ausgewählte Dateien:",
|
||||
"uploadProgress": "Upload: {current} / {total}",
|
||||
"assignGenresLabel": "Genres zuordnen",
|
||||
"assignSpecialsLabel": "Specials zuordnen",
|
||||
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
|
||||
"uploadButtonIdle": "Upload starten",
|
||||
"uploadButtonUploading": "Lade hoch...",
|
||||
@@ -311,12 +314,12 @@
|
||||
"uploadTitle": "Songs hochladen",
|
||||
"uploadStepsTitle": "Schritt-für-Schritt-Anleitung",
|
||||
"uploadStep1": "MP3-Dateien in den Upload-Bereich ziehen oder klicken, um Dateien auszuwählen",
|
||||
"uploadStep2": "Ein oder mehrere Genres auswählen, um sie den hochgeladenen Songs zuzuordnen",
|
||||
"uploadStep2": "Ein oder mehrere Genres und – falls passend – Specials auswählen, um sie den hochgeladenen Songs zuzuordnen",
|
||||
"uploadStep3": "Auf 'Upload starten' klicken, um den Upload-Prozess zu beginnen",
|
||||
"uploadStep4": "Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus den Dateien",
|
||||
"uploadBestPracticesTitle": "Best Practices",
|
||||
"uploadBestPractice1": "Stelle sicher, dass MP3-Dateien korrekte ID3-Tags (Titel, Artist) für die automatische Metadaten-Extraktion haben",
|
||||
"uploadBestPractice2": "Passende Genres vor dem Upload auswählen, um spätere manuelle Zuordnung zu vermeiden",
|
||||
"uploadBestPractice2": "Passende Genres (und Specials) vor dem Upload auswählen, um spätere manuelle Zuordnung zu vermeiden",
|
||||
"uploadBestPractice3": "Vor dem Upload auf Duplikate prüfen - das System warnt dich, wenn ein Song bereits existiert",
|
||||
"tip": "Tipp",
|
||||
"uploadTip": "Alle von Kuratoren hochgeladenen Songs werden automatisch von der globalen Playlist ausgeschlossen. Nur Admins können diese Einstellung ändern.",
|
||||
@@ -353,12 +356,22 @@
|
||||
"troubleshootingA3": "Du kannst Songs nur Genres und Specials zuordnen, für die du verantwortlich bist. Wende dich an den Admin, wenn du Zugriff auf zusätzliche Genres oder Specials benötigst.",
|
||||
"troubleshootingQ4": "Warum ist die Exclude-Global-Checkbox deaktiviert?",
|
||||
"troubleshootingA4": "Nur Global-Kuratoren können das Exclude-Global-Flag ändern. Wenn du diese Berechtigung benötigst, wende dich an den Admin.",
|
||||
"curateSpecialsHelpTitle": "Specials kuratieren",
|
||||
"curateSpecialsHelpIntro": "Im Bereich „Curate Specials\" kannst du den exakten Audio-Ausschnitt festlegen, den Spieler in deinen Specials hören. Es werden nur Specials angezeigt, die dir zugewiesen sind.",
|
||||
"curateSpecialsHelpStepsTitle": "So kuratierst du Specials",
|
||||
"curateSpecialsHelpStep1": "Öffne das Kuratoren-Dashboard und klicke auf „Curate Specials\", um alle dir zugewiesenen Specials zu sehen.",
|
||||
"curateSpecialsHelpStep2": "Wähle ein Special aus der Liste, um den Waveform-Editor für dieses Special zu öffnen.",
|
||||
"curateSpecialsHelpStep3": "Klicke auf die Waveform, um die Startzeit zu wählen. Der hervorgehobene Bereich zeigt genau das, was Spieler hören werden.",
|
||||
"curateSpecialsHelpStep4": "Nutze Zoom, Pan und Segment-Playback, um den Ausschnitt fein abzustimmen. Klicke auf „Änderungen speichern\", um die neue Startzeit zu übernehmen.",
|
||||
"curateSpecialsPermissionsNote": "Du kannst nur Specials kuratieren, die dir zugewiesen sind. Wenn du versuchst, ein fremdes Special zu öffnen oder zu speichern, blockiert das System die Aktion.",
|
||||
"tooltipDashboardShort": "Übersicht über dein Kuratoren-Dashboard",
|
||||
"tooltipDashboardLong": "Dies ist dein Haupt-Dashboard, wo du Songs hochladen, deine Track-Liste verwalten und Kommentare von Spielern einsehen kannst. Nutze den Hilfe-Button (❓), um auf das vollständige Handbuch zuzugreifen.",
|
||||
"tooltipUploadShort": "MP3-Dateien zu deinen Genres hochladen",
|
||||
"tooltipUploadLong": "Ziehe MP3-Dateien per Drag & Drop oder klicke, um sie auszuwählen. Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus ID3-Tags. Wähle Genres vor dem Upload aus, um Songs automatisch zuzuordnen. Alle Kuratoren-Uploads sind standardmäßig von der globalen Playlist ausgeschlossen.",
|
||||
"tooltipGenreAssignmentShort": "Genres zu hochgeladenen Songs zuordnen",
|
||||
"tooltipGenreAssignmentLong": "Wähle ein oder mehrere Genres vor dem Upload aus. Die ausgewählten Genres werden allen erfolgreich hochgeladenen Songs zugeordnet. Du kannst nur Genres zuordnen, für die du verantwortlich bist. Wenn du keine Genres auswählst, kannst du sie später durch Bearbeitung der Songs zuordnen.",
|
||||
"tooltipSpecialAssignmentShort": "Specials zu hochgeladenen Songs zuordnen",
|
||||
"tooltipSpecialAssignmentLong": "Wähle ein oder mehrere Specials vor dem Upload aus. Die ausgewählten Specials werden allen erfolgreich hochgeladenen Songs zugeordnet. Du kannst nur Specials zuordnen, für die du verantwortlich bist. Wenn du keine Specials auswählst, kannst du sie später durch Bearbeitung der Songs zuordnen.",
|
||||
"tooltipTracklistShort": "Deine Songs verwalten",
|
||||
"tooltipTracklistLong": "Diese Tabelle zeigt alle Songs in deinen Genres und Specials. Du kannst suchen, filtern, sortieren und Songs bearbeiten. Nutze die Checkboxen, um mehrere Songs für die Batch-Bearbeitung auszuwählen. Nur Songs, die du bearbeiten kannst, haben eine aktive Checkbox.",
|
||||
"tooltipSearchShort": "Nach Titel oder Artist suchen",
|
||||
@@ -374,7 +387,11 @@
|
||||
"tooltipBatchArtistShort": "Artist für alle ausgewählten Songs ändern",
|
||||
"tooltipBatchArtistLong": "Gib einen neuen Artist-Namen ein, um ihn für alle ausgewählten Songs zu setzen. Dies ist nützlich, um Artist-Namen zu korrigieren oder Namenskonventionen über mehrere Songs hinweg zu standardisieren.",
|
||||
"tooltipCommentsShort": "Spieler-Feedback und Kommentare",
|
||||
"tooltipCommentsLong": "Spieler können dir Nachrichten zu Rätseln in deinen Genres oder Specials senden. Ungelesene Kommentare sind mit einem blauen Rahmen und Badge hervorgehoben. Klicke auf einen Kommentar, um ihn als gelesen zu markieren, oder archiviere ihn, wenn du ihn nicht mehr benötigst."
|
||||
"tooltipCommentsLong": "Spieler können dir Nachrichten zu Rätseln in deinen Genres oder Specials senden. Ungelesene Kommentare sind mit einem blauen Rahmen und Badge hervorgehoben. Klicke auf einen Kommentar, um ihn als gelesen zu markieren, oder archiviere ihn, wenn du ihn nicht mehr benötigst.",
|
||||
"tooltipCurateSpecialsShort": "Startzeiten für deine Specials kuratieren",
|
||||
"tooltipCurateSpecialsLong": "In dieser Ansicht siehst du alle Specials, die dir zugewiesen sind. Öffne ein Special, um den Audio-Ausschnitt zu wählen, den die Spieler hören. Du kannst nur Specials sehen und bearbeiten, für die du zuständig bist.",
|
||||
"tooltipCurateSpecialEditorShort": "Mit dem Waveform-Editor den Puzzle-Ausschnitt wählen",
|
||||
"tooltipCurateSpecialEditorLong": "Klicke auf die Waveform, um zu bestimmen, wo das Rätsel startet. Nutze Zoom und Pan für Feineinstellungen und spiele einzelne Segmente ab, um sie zu testen. Beim Speichern wird nur dieser kuratierte Ausschnitt für Spieler in diesem Special verwendet."
|
||||
},
|
||||
"About": {
|
||||
"title": "Über Hördle & Impressum",
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
"sendCommentCollapsed": "Send message to curator",
|
||||
"commentPlaceholder": "Write a message to the curators of this genre... Please remain friendly and polite.",
|
||||
"commentHelp": "Share your thoughts on the puzzle with the curators. Your message will be shown to them.",
|
||||
"commentAIConsent": "I agree that this message will be processed by an AI to filter unfriendly messages.",
|
||||
"commentSent": "✓ Message sent! Thank you for your feedback.",
|
||||
"commentThankYou": "Thank you for your feedback!",
|
||||
"commentRewritten": "Your message was automatically rephrased to be more friendly:",
|
||||
"commentError": "Error sending message",
|
||||
"commentRateLimited": "You have already sent a message for this puzzle.",
|
||||
@@ -203,6 +205,7 @@
|
||||
"selectedFilesTitle": "Selected files:",
|
||||
"uploadProgress": "Upload: {current} / {total}",
|
||||
"assignGenresLabel": "Assign genres",
|
||||
"assignSpecialsLabel": "Assign specials",
|
||||
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
|
||||
"uploadButtonIdle": "Start upload",
|
||||
"uploadButtonUploading": "Uploading...",
|
||||
@@ -311,12 +314,12 @@
|
||||
"uploadTitle": "Uploading Songs",
|
||||
"uploadStepsTitle": "Step-by-Step Guide",
|
||||
"uploadStep1": "Drag MP3 files into the upload area or click to select files",
|
||||
"uploadStep2": "Select one or more genres to assign to the uploaded songs",
|
||||
"uploadStep2": "Select one or more genres and, if applicable, specials to assign to the uploaded songs",
|
||||
"uploadStep3": "Click 'Start upload' to begin the upload process",
|
||||
"uploadStep4": "The system will automatically extract metadata (title, artist, release year) from the files",
|
||||
"uploadBestPracticesTitle": "Best Practices",
|
||||
"uploadBestPractice1": "Ensure MP3 files have correct ID3 tags (title, artist) for automatic metadata extraction",
|
||||
"uploadBestPractice2": "Select appropriate genres before uploading to avoid manual assignment later",
|
||||
"uploadBestPractice2": "Select appropriate genres (and specials) before uploading to avoid manual assignment later",
|
||||
"uploadBestPractice3": "Check for duplicates before uploading - the system will warn you if a song already exists",
|
||||
"tip": "Tip",
|
||||
"uploadTip": "All songs uploaded by curators are automatically excluded from the global playlist. Only admins can change this setting.",
|
||||
@@ -353,12 +356,22 @@
|
||||
"troubleshootingA3": "You can only assign songs to genres and specials that you are responsible for. Contact the admin if you need access to additional genres or specials.",
|
||||
"troubleshootingQ4": "Why is the exclude-from-global checkbox disabled?",
|
||||
"troubleshootingA4": "Only Global Curators can change the exclude-from-global flag. If you need this permission, contact the admin.",
|
||||
"curateSpecialsHelpTitle": "Curating specials",
|
||||
"curateSpecialsHelpIntro": "In the \"Curate Specials\" area you can choose the exact audio snippet that players will hear in your specials. You only ever see specials that are assigned to you.",
|
||||
"curateSpecialsHelpStepsTitle": "How to curate specials",
|
||||
"curateSpecialsHelpStep1": "Open the curator dashboard and click on \"Curate Specials\" to see all specials assigned to you.",
|
||||
"curateSpecialsHelpStep2": "Select a special from the list to open the waveform editor for that special.",
|
||||
"curateSpecialsHelpStep3": "Click on the waveform to choose the start time. The highlighted region shows exactly what players will hear.",
|
||||
"curateSpecialsHelpStep4": "Use zoom, pan and segment playback to fine-tune the snippet. Click \"Save changes\" to apply the new start time.",
|
||||
"curateSpecialsPermissionsNote": "You can only curate specials that are assigned to you. If you try to open or save a special that is not yours, the system will block the action.",
|
||||
"tooltipDashboardShort": "Overview of your curator dashboard",
|
||||
"tooltipDashboardLong": "This is your main dashboard where you can upload songs, manage your track list, and view comments from players. Use the help button (❓) to access the full manual.",
|
||||
"tooltipUploadShort": "Upload MP3 files to your genres",
|
||||
"tooltipUploadLong": "Drag and drop MP3 files or click to select. The system will automatically extract metadata (title, artist, release year) from ID3 tags. Select genres before uploading to automatically assign songs. All curator uploads are excluded from the global playlist by default.",
|
||||
"tooltipGenreAssignmentShort": "Assign genres to uploaded songs",
|
||||
"tooltipGenreAssignmentLong": "Select one or more genres before uploading. The selected genres will be assigned to all successfully uploaded songs. You can only assign genres that you are responsible for. If you don't select any genres, you can assign them later by editing the songs.",
|
||||
"tooltipSpecialAssignmentShort": "Assign specials to uploaded songs",
|
||||
"tooltipSpecialAssignmentLong": "Select one or more specials before uploading. The selected specials will be assigned to all successfully uploaded songs. You can only assign specials that you are responsible for. If you don't select any specials, you can assign them later by editing the songs.",
|
||||
"tooltipTracklistShort": "Manage your songs",
|
||||
"tooltipTracklistLong": "This table shows all songs in your genres and specials. You can search, filter, sort, and edit songs. Use the checkboxes to select multiple songs for batch editing. Only songs you can edit will have an active checkbox.",
|
||||
"tooltipSearchShort": "Search by title or artist",
|
||||
@@ -374,7 +387,11 @@
|
||||
"tooltipBatchArtistShort": "Change artist for all selected songs",
|
||||
"tooltipBatchArtistLong": "Enter a new artist name to set it for all selected songs. This is useful for correcting artist names or standardizing naming conventions across multiple songs.",
|
||||
"tooltipCommentsShort": "Player feedback and comments",
|
||||
"tooltipCommentsLong": "Players can send you messages about puzzles in your genres or specials. Unread comments are highlighted with a blue border and badge. Click on a comment to mark it as read, or archive it if you no longer need it."
|
||||
"tooltipCommentsLong": "Players can send you messages about puzzles in your genres or specials. Unread comments are highlighted with a blue border and badge. Click on a comment to mark it as read, or archive it if you no longer need it.",
|
||||
"tooltipCurateSpecialsShort": "Curate the start times for your specials",
|
||||
"tooltipCurateSpecialsLong": "This view shows all specials that are assigned to you. Open a special to choose the audio snippet that players will hear. You can only see and edit specials for which you are responsible.",
|
||||
"tooltipCurateSpecialEditorShort": "Use the waveform editor to pick the puzzle snippet",
|
||||
"tooltipCurateSpecialEditorLong": "Click on the waveform to choose where the puzzle starts. Use zoom and pan for fine control, and play back individual segments to test them. When you save, only this curated snippet will be used for players in this special."
|
||||
},
|
||||
"About": {
|
||||
"title": "About Hördle & Imprint",
|
||||
|
||||
100
package-lock.json
generated
100
package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.6.11",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"driver.js": "^1.4.0",
|
||||
"music-metadata": "^11.10.2",
|
||||
"next": "16.0.3",
|
||||
"next": "^16.0.7",
|
||||
"next-intl": "^4.5.6",
|
||||
"prisma": "^6.19.0",
|
||||
"react": "19.2.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"eslint-config-next": "^16.0.7",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
@@ -1101,15 +1101,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.3.tgz",
|
||||
"integrity": "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz",
|
||||
"integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.3.tgz",
|
||||
"integrity": "sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.7.tgz",
|
||||
"integrity": "sha512-hFrTNZcMEG+k7qxVxZJq3F32Kms130FAhG8lvw2zkKBgAcNOJIxlljNiCjGygvBshvaGBdf88q2CqWtnqezDHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1117,9 +1117,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.3.tgz",
|
||||
"integrity": "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz",
|
||||
"integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1133,9 +1133,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.3.tgz",
|
||||
"integrity": "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz",
|
||||
"integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1149,9 +1149,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.3.tgz",
|
||||
"integrity": "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1165,9 +1165,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.3.tgz",
|
||||
"integrity": "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1181,9 +1181,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.3.tgz",
|
||||
"integrity": "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz",
|
||||
"integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1197,9 +1197,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.3.tgz",
|
||||
"integrity": "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz",
|
||||
"integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1213,9 +1213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.3.tgz",
|
||||
"integrity": "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1229,9 +1229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.3.tgz",
|
||||
"integrity": "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz",
|
||||
"integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3474,13 +3474,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.3.tgz",
|
||||
"integrity": "sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.7.tgz",
|
||||
"integrity": "sha512-WubFGLFHfk2KivkdRGfx6cGSFhaQqhERRfyO8BRx+qiGPGp7WLKcPvYC4mdx1z3VhVRcrfFzczjjTrbJZOpnEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.0.3",
|
||||
"@next/eslint-plugin-next": "16.0.7",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -5945,12 +5945,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
|
||||
"integrity": "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==",
|
||||
"version": "16.0.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz",
|
||||
"integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.0.3",
|
||||
"@next/env": "16.0.7",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -5963,14 +5963,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.0.3",
|
||||
"@next/swc-darwin-x64": "16.0.3",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.3",
|
||||
"@next/swc-linux-arm64-musl": "16.0.3",
|
||||
"@next/swc-linux-x64-gnu": "16.0.3",
|
||||
"@next/swc-linux-x64-musl": "16.0.3",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.3",
|
||||
"@next/swc-win32-x64-msvc": "16.0.3",
|
||||
"@next/swc-darwin-arm64": "16.0.7",
|
||||
"@next/swc-darwin-x64": "16.0.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.0.7",
|
||||
"@next/swc-linux-arm64-musl": "16.0.7",
|
||||
"@next/swc-linux-x64-gnu": "16.0.7",
|
||||
"@next/swc-linux-x64-musl": "16.0.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.0.7",
|
||||
"@next/swc-win32-x64-msvc": "16.0.7",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.6.3",
|
||||
"version": "0.1.6.13",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -13,7 +13,7 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"driver.js": "^1.4.0",
|
||||
"music-metadata": "^11.10.2",
|
||||
"next": "16.0.3",
|
||||
"next": "^16.0.7",
|
||||
"next-intl": "^4.5.6",
|
||||
"prisma": "^6.19.0",
|
||||
"react": "19.2.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"eslint-config-next": "^16.0.7",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
set -e
|
||||
|
||||
if [ -f "$HOME/.restic-env" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "$HOME/.restic-env"
|
||||
fi
|
||||
|
||||
echo "💾 Creating Restic backup..."
|
||||
|
||||
if ! command -v restic >/dev/null 2>&1; then
|
||||
|
||||
@@ -88,10 +88,13 @@ docker compose build
|
||||
echo "🔄 Restarting with new image (minimal downtime)..."
|
||||
docker compose up -d
|
||||
|
||||
# Clean up old images
|
||||
# Clean up old images and build cache
|
||||
echo "🧹 Cleaning up old images..."
|
||||
docker image prune -f
|
||||
|
||||
echo "🧹 Cleaning up build cache..."
|
||||
docker builder prune -f
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
echo ""
|
||||
echo "📊 Showing logs (Ctrl+C to exit)..."
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Optional: Restic-Umgebungsvariablen aus ~/.restic-env laden
|
||||
if [ -f "$HOME/.restic-env" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. "$HOME/.restic-env"
|
||||
fi
|
||||
|
||||
echo "💾 Restoring from Restic backup..."
|
||||
|
||||
if ! command -v restic >/dev/null 2>&1; then
|
||||
|
||||
Reference in New Issue
Block a user