feat(folders): Tabs für Unterordner + rekursive Sound-Liste; Play mit relativePath; UI-Tabs
This commit is contained in:
parent
24de686a54
commit
f9bec8b5a1
5 changed files with 85 additions and 16 deletions
|
|
@ -190,17 +190,40 @@ app.get('/api/health', (_req: Request, res: Response) => {
|
||||||
|
|
||||||
app.get('/api/sounds', (req: Request, res: Response) => {
|
app.get('/api/sounds', (req: Request, res: Response) => {
|
||||||
const q = String(req.query.q ?? '').toLowerCase();
|
const q = String(req.query.q ?? '').toLowerCase();
|
||||||
const files = fs
|
|
||||||
.readdirSync(SOUNDS_DIR, { withFileTypes: true })
|
const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true });
|
||||||
|
const rootFiles = rootEntries
|
||||||
.filter((d) => d.isFile() && d.name.toLowerCase().endsWith('.mp3'))
|
.filter((d) => d.isFile() && d.name.toLowerCase().endsWith('.mp3'))
|
||||||
.map((d) => d.name)
|
.map((d) => ({ fileName: d.name, name: path.parse(d.name).name, folder: '', relativePath: d.name }));
|
||||||
.sort((a, b) => a.localeCompare(b));
|
|
||||||
|
|
||||||
const items = files
|
const folders: Array<{ key: string; name: string; count: number }> = [];
|
||||||
.map((file) => ({ fileName: file, name: path.parse(file).name }))
|
|
||||||
.filter((s) => (q ? s.name.toLowerCase().includes(q) : true));
|
|
||||||
|
|
||||||
res.json({ items, total: files.length });
|
const subFolders = rootEntries.filter((d) => d.isDirectory());
|
||||||
|
const folderItems: Array<{ fileName: string; name: string; folder: string; relativePath: string }> = [];
|
||||||
|
for (const dirent of subFolders) {
|
||||||
|
const folderName = dirent.name;
|
||||||
|
const folderPath = path.join(SOUNDS_DIR, folderName);
|
||||||
|
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
||||||
|
const mp3s = entries.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.mp3'));
|
||||||
|
for (const f of mp3s) {
|
||||||
|
folderItems.push({
|
||||||
|
fileName: f.name,
|
||||||
|
name: path.parse(f.name).name,
|
||||||
|
folder: folderName,
|
||||||
|
relativePath: path.join(folderName, f.name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
folders.push({ key: folderName, name: folderName, count: mp3s.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allItems = [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const filteredItems = allItems.filter((s) => (q ? s.name.toLowerCase().includes(q) : true));
|
||||||
|
|
||||||
|
const total = allItems.length;
|
||||||
|
const rootCount = rootFiles.length;
|
||||||
|
const foldersOut = [{ key: '__all__', name: 'Alle', count: total }, { key: '', name: 'Root', count: rootCount }, ...folders];
|
||||||
|
|
||||||
|
res.json({ items: filteredItems, total, folders: foldersOut });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/channels', (_req: Request, res: Response) => {
|
app.get('/api/channels', (_req: Request, res: Response) => {
|
||||||
|
|
@ -223,15 +246,20 @@ app.get('/api/channels', (_req: Request, res: Response) => {
|
||||||
|
|
||||||
app.post('/api/play', async (req: Request, res: Response) => {
|
app.post('/api/play', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { soundName, guildId, channelId, volume } = req.body as {
|
const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as {
|
||||||
soundName?: string;
|
soundName?: string;
|
||||||
guildId?: string;
|
guildId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
volume?: number; // 0..1
|
volume?: number; // 0..1
|
||||||
|
folder?: string; // optional subfolder key
|
||||||
|
relativePath?: string; // optional direct relative path
|
||||||
};
|
};
|
||||||
if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' });
|
if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' });
|
||||||
|
|
||||||
const filePath = path.join(SOUNDS_DIR, `${soundName}.mp3`);
|
let filePath: string;
|
||||||
|
if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath);
|
||||||
|
else if (folder) filePath = path.join(SOUNDS_DIR, folder, `${soundName}.mp3`);
|
||||||
|
else filePath = path.join(SOUNDS_DIR, `${soundName}.mp3`);
|
||||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' });
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' });
|
||||||
|
|
||||||
const guild = client.guilds.cache.get(guildId);
|
const guild = client.guilds.cache.get(guildId);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import type { VoiceChannelInfo, Sound } from './types';
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [sounds, setSounds] = useState<Sound[]>([]);
|
const [sounds, setSounds] = useState<Sound[]>([]);
|
||||||
const [total, setTotal] = useState<number>(0);
|
const [total, setTotal] = useState<number>(0);
|
||||||
|
const [folders, setFolders] = useState<Array<{ key: string; name: string; count: number }>>([]);
|
||||||
|
const [activeFolder, setActiveFolder] = useState<string>('__all__');
|
||||||
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [selected, setSelected] = useState<string>('');
|
const [selected, setSelected] = useState<string>('');
|
||||||
|
|
@ -18,6 +20,7 @@ export default function App() {
|
||||||
const [s, c] = await Promise.all([fetchSounds(), fetchChannels()]);
|
const [s, c] = await Promise.all([fetchSounds(), fetchChannels()]);
|
||||||
setSounds(s.items);
|
setSounds(s.items);
|
||||||
setTotal(s.total);
|
setTotal(s.total);
|
||||||
|
setFolders(s.folders);
|
||||||
setChannels(c);
|
setChannels(c);
|
||||||
if (c[0]) setSelected(`${c[0].guildId}:${c[0].channelId}`);
|
if (c[0]) setSelected(`${c[0].guildId}:${c[0].channelId}`);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -30,6 +33,7 @@ export default function App() {
|
||||||
const s = await fetchSounds(query);
|
const s = await fetchSounds(query);
|
||||||
setSounds(s.items);
|
setSounds(s.items);
|
||||||
setTotal(s.total);
|
setTotal(s.total);
|
||||||
|
setFolders(s.folders);
|
||||||
} catch {}
|
} catch {}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
|
|
@ -41,13 +45,13 @@ export default function App() {
|
||||||
return sounds.filter((s) => s.name.toLowerCase().includes(q));
|
return sounds.filter((s) => s.name.toLowerCase().includes(q));
|
||||||
}, [sounds, query]);
|
}, [sounds, query]);
|
||||||
|
|
||||||
async function handlePlay(name: string) {
|
async function handlePlay(name: string, rel?: string) {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!selected) return setError('Bitte einen Voice-Channel auswählen');
|
if (!selected) return setError('Bitte einen Voice-Channel auswählen');
|
||||||
const [guildId, channelId] = selected.split(':');
|
const [guildId, channelId] = selected.split(':');
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await playSound(name, guildId, channelId, volume);
|
await playSound(name, guildId, channelId, volume, rel);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || 'Play fehlgeschlagen');
|
setError(e?.message || 'Play fehlgeschlagen');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -101,11 +105,31 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{folders.length > 0 && (
|
||||||
|
<nav className="tabs">
|
||||||
|
{folders.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
className={`tab ${activeFolder === f.key ? 'active' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setActiveFolder(f.key);
|
||||||
|
const resp = await fetchSounds(query, f.key);
|
||||||
|
setSounds(resp.items);
|
||||||
|
setTotal(resp.total);
|
||||||
|
setFolders(resp.folders);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.name} ({f.count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
<section className="grid">
|
<section className="grid">
|
||||||
{filtered.map((s) => (
|
{filtered.map((s) => (
|
||||||
<button key={s.fileName} className="sound" onClick={() => handlePlay(s.name)} disabled={loading}>
|
<button key={`${s.fileName}-${s.name}`} className="sound" onClick={() => handlePlay(s.name, s.relativePath)} disabled={loading}>
|
||||||
{s.name}
|
{s.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -116,6 +140,10 @@ export default function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePlayWithPathFactory(play: (name: string, rel?: string) => Promise<void>) {
|
||||||
|
return (s: Sound & { relativePath?: string }) => play(s.name, s.relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { Sound, SoundsResponse, VoiceChannelInfo } from './types';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||||
|
|
||||||
export async function fetchSounds(q?: string): Promise<SoundsResponse> {
|
export async function fetchSounds(q?: string, folderKey?: string): Promise<SoundsResponse> {
|
||||||
const url = new URL(`${API_BASE}/sounds`, window.location.origin);
|
const url = new URL(`${API_BASE}/sounds`, window.location.origin);
|
||||||
if (q) url.searchParams.set('q', q);
|
if (q) url.searchParams.set('q', q);
|
||||||
const res = await fetch(url.toString());
|
const res = await fetch(url.toString());
|
||||||
|
|
@ -16,11 +16,11 @@ export async function fetchChannels(): Promise<VoiceChannelInfo[]> {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function playSound(soundName: string, guildId: string, channelId: string, volume: number): Promise<void> {
|
export async function playSound(soundName: string, guildId: string, channelId: string, volume: number, relativePath?: string): Promise<void> {
|
||||||
const res = await fetch(`${API_BASE}/play`, {
|
const res = await fetch(`${API_BASE}/play`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ soundName, guildId, channelId, volume })
|
body: JSON.stringify({ soundName, guildId, channelId, volume, relativePath })
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,16 @@ header p { opacity: .8; }
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||||
|
.tab {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255,255,255,.12);
|
||||||
|
background: rgba(18,24,48,.6);
|
||||||
|
color: #e7e7ee;
|
||||||
|
}
|
||||||
|
.tab.active { background: linear-gradient(135deg, rgba(88,28,135,.9), rgba(59,130,246,.9)); color: #fff; border-color: transparent; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
export type Sound = {
|
export type Sound = {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
folder?: string;
|
||||||
|
relativePath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SoundsResponse = {
|
export type SoundsResponse = {
|
||||||
items: Sound[];
|
items: Sound[];
|
||||||
total: number;
|
total: number;
|
||||||
|
folders: Array<{ key: string; name: string; count: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VoiceChannelInfo = {
|
export type VoiceChannelInfo = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue