commit c39f5fce0c278f50bd7ec1223d3abeca97ef66db Author: vibe-bot Date: Thu Aug 7 23:24:56 2025 +0200 clean: initial commit ohne Secrets diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b4de4ee --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +**/node_modules + +.git +.github + +# Local data +/data + +# Build outputs +server/dist +web/dist + +# env files +.env +.env.* diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..448cc1b --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,35 @@ +name: Build and Push Docker image + +on: + push: + branches: [ "main" ] + workflow_dispatch: + +env: + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/discord-soundboard + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ github.sha }} + platforms: linux/amd64 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d462215 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Env +.env +.env.* + +# Node +node_modules/ +**/node_modules/ + +# Build outputs +server/dist/ +web/dist/ + +# IDE +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Runtime data +/data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..24dd8c3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# Multi-stage build: Frontend (Vite) + Backend (Express + discord.js) + +# --- Build frontend (npm) --- +FROM node:20-slim AS web-build +WORKDIR /app/web +COPY web/package*.json ./ +RUN npm install --no-audit --no-fund +COPY web/ . +RUN npm run build + +# --- Build server (npm) --- +FROM node:20-slim AS server-build +WORKDIR /app/server +RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* +COPY server/package*.json ./ +RUN npm install --no-audit --no-fund +COPY server/ . +RUN npm run build +# Nur Prod-Dependencies für Runtime behalten +RUN npm prune --omit=dev + +# --- Runtime image --- +FROM node:20-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=8080 +ENV SOUNDS_DIR=/data/sounds + +RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/* + +COPY --from=server-build /app/server/dist ./server/dist +COPY --from=server-build /app/server/node_modules ./server/node_modules +COPY --from=server-build /app/server/package.json ./server/package.json +COPY --from=web-build /app/web/dist ./web/dist + +EXPOSE 8080 +VOLUME ["/data/sounds"] +CMD ["node", "server/dist/index.js"] + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd6bb33 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Discord Soundboard (Docker) + +Eine Docker-fähige Web-App mit Discord-Bot. Nutzer wählen im Web-Frontend Sound-Dateien, wählen einen Discord-Sprachkanal per Dropdown und lassen den Bot dort den Sound abspielen. Neue Sounds können dem Bot per privater Discord-Nachricht als MP3 gesendet werden; der Bot speichert sie automatisch und sie erscheinen im Frontend. + +Inspiration/Referenz: `davefurrer/discordsoundboard` auf Docker Hub ([Link](https://hub.docker.com/r/davefurrer/discordsoundboard)). + +## Features +- Schönes Web-Frontend mit Suchfeld und Buttons für Sounds +- Dropdown-Auswahl der verfügbaren Discord-Sprachkanäle (guild-übergreifend) +- Discord-Bot joint den gewählten Voice-Channel und spielt MP3-Sounds +- MP3-Uploads via DM an den Bot; automatische Ablage im Backend und sofort im Frontend sichtbar +- Einfache Installation via Docker Compose + +## Architektur +- Ein Container (Node.js): + - Express REST-API und statische Auslieferung des Frontends + - Discord-Bot (discord.js, @discordjs/voice, ffmpeg) + - Gemeinsames Datenverzeichnis `/data/sounds` für MP3s + +## Anforderungen +- Docker und Docker Compose +- Discord Bot Token mit Intents: Guilds, GuildVoiceStates, DirectMessages, MessageContent + +## Schnellstart + +1) `.env` anlegen (siehe `.env.example`). + +2) Docker bauen und starten: + +```bash +docker compose up --build -d +``` + +3) Öffne `http://localhost:8080` im Browser. + +4) Lade dem Bot per privater Nachricht eine `.mp3` hoch. Der Sound erscheint automatisch im Frontend. + +## Umgebungsvariablen (`.env`) + +``` +DISCORD_TOKEN=dein_discord_bot_token +PORT=8080 +SOUNDS_DIR=/data/sounds +``` + +Optional: Du kannst die Liste der Kanäle auf bestimmte Guilds beschränken: + +``` +ALLOWED_GUILD_IDS=GUILD_ID_1,GUILD_ID_2 +``` + +## Endpunkte (API) +- `GET /api/health` – Healthcheck +- `GET /api/sounds` – Liste der Sounds +- `GET /api/channels` – Liste der Voice-Channels (mit Guild-Infos) +- `POST /api/play` – Body: `{ soundName, guildId, channelId }` + +## Entwicklung lokal (ohne Docker) + +1) Server: +```bash +cd server +npm install +npm run dev +``` + +2) Web: +```bash +cd web +npm install +npm run dev +``` + +Das Web-Frontend erwartet die API standardmäßig unter `http://localhost:8080/api`. Passe sonst `VITE_API_BASE_URL` an. + +## Veröffentlichung auf Docker Hub + +1) Image bauen: +```bash +docker build -t /discord-soundboard:latest . +``` + +2) Einloggen und pushen: +```bash +docker login +docker push /discord-soundboard:latest +``` + +3) Installation irgendwo: +```bash +docker pull /discord-soundboard:latest +docker run --name discord-soundboard -p 8080:8080 --env-file .env -v $(pwd)/data/sounds:/data/sounds -d /discord-soundboard:latest +``` + +Hinweis: Dieses Projekt ist eigenständig implementiert, angelehnt an die Funktionsbeschreibung von `davefurrer/discordsoundboard` ([Link](https://hub.docker.com/r/davefurrer/discordsoundboard)). + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..071bb9c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + app: + build: . + container_name: discord-soundboard + ports: + - "8080:8080" + env_file: + - .env + volumes: + - ./data/sounds:/data/sounds + restart: unless-stopped + + + diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..f922169 --- /dev/null +++ b/server/package.json @@ -0,0 +1,33 @@ +{ + "name": "discord-soundboard-server", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "ts-node src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js" + }, + "dependencies": { + "@discordjs/voice": "^0.17.0", + "@discordjs/opus": "^0.9.0", + "discord.js": "^14.16.3", + "express": "^4.19.2", + "libsodium-wrappers": "^0.7.13", + "tweetnacl": "^1.0.3", + "sodium-native": "^4.0.8", + "cors": "^2.8.5", + "multer": "^1.4.5-lts.1", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.12.12", + "ts-node": "^10.9.2", + "typescript": "^5.5.4" + } +} + + diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml new file mode 100644 index 0000000..4f821a3 --- /dev/null +++ b/server/pnpm-lock.yaml @@ -0,0 +1,15 @@ +lockfileVersion: '9.0' +dependencies: + '@discordjs/voice': ^0.17.0 + discord.js: ^14.16.3 + express: ^4.19.2 + multer: ^1.4.5-lts.1 + ws: ^8.18.0 +devDependencies: + ts-node: ^10.9.2 + typescript: ^5.5.4 + + + + + diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..06ff7c1 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,215 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import express, { Request, Response } from 'express'; +import cors from 'cors'; +import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message } from 'discord.js'; +import { + joinVoiceChannel, + createAudioPlayer, + createAudioResource, + AudioPlayerStatus, + NoSubscriberBehavior, + getVoiceConnection, + type VoiceConnection, + generateDependencyReport +} from '@discordjs/voice'; +import sodium from 'libsodium-wrappers'; +import nacl from 'tweetnacl'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// --- Config --- +const PORT = Number(process.env.PORT ?? 8080); +const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds'; +const DISCORD_TOKEN = process.env.DISCORD_TOKEN ?? ''; +const ALLOWED_GUILD_IDS = (process.env.ALLOWED_GUILD_IDS ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +if (!DISCORD_TOKEN) { + console.error('Fehlende Umgebungsvariable DISCORD_TOKEN'); + process.exit(1); +} + +fs.mkdirSync(SOUNDS_DIR, { recursive: true }); + +// --- Voice Abhängigkeiten prüfen --- +await sodium.ready; +// init nacl to ensure it loads +void nacl.randomBytes(1); +console.log(generateDependencyReport()); + +// --- Discord Client --- +const client = new Client({ + // 32385 = Guilds + GuildVoiceStates + GuildMessages + GuildMessageReactions + GuildMessageTyping + // + DirectMessages + DirectMessageReactions + DirectMessageTyping + // (ohne privilegierte Intents wie MessageContent/GuildMembers/Presences) + intents: 32385, + partials: [Partials.Channel] +}); + +type GuildAudioState = { connection: VoiceConnection; player: ReturnType }; +const guildAudioState = new Map(); + +client.once(Events.ClientReady, () => { + console.log(`Bot eingeloggt als ${client.user?.tag}`); +}); + +client.on(Events.MessageCreate, async (message: Message) => { + try { + if (message.author?.bot) return; + if (!message.channel?.isDMBased?.()) return; + if (message.attachments.size === 0) return; + + for (const [, attachment] of message.attachments) { + const name = attachment.name ?? 'upload.mp3'; + const lower = name.toLowerCase(); + if (!lower.endsWith('.mp3')) continue; + + const safeName = name.replace(/[^a-zA-Z0-9_.-]/g, '_'); + let targetPath = path.join(SOUNDS_DIR, safeName); + if (fs.existsSync(targetPath)) { + const base = path.parse(safeName).name; + const ext = path.parse(safeName).ext || '.mp3'; + let i = 2; + while (fs.existsSync(targetPath)) { + targetPath = path.join(SOUNDS_DIR, `${base}-${i}${ext}`); + i += 1; + } + } + + const res = await fetch(attachment.url); + if (!res.ok) throw new Error(`Download fehlgeschlagen: ${attachment.url}`); + const arrayBuffer = await res.arrayBuffer(); + fs.writeFileSync(targetPath, Buffer.from(arrayBuffer)); + await message.reply(`Sound gespeichert: ${path.basename(targetPath)}`); + } + } catch (err) { + console.error('Fehler bei DM-Upload:', err); + } +}); + +await client.login(DISCORD_TOKEN); + +// --- Express App --- +const app = express(); +app.use(express.json()); +app.use(cors()); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true }); +}); + +app.get('/api/sounds', (req: Request, res: Response) => { + const q = String(req.query.q ?? '').toLowerCase(); + const files = fs + .readdirSync(SOUNDS_DIR, { withFileTypes: true }) + .filter((d) => d.isFile() && d.name.toLowerCase().endsWith('.mp3')) + .map((d) => d.name) + .sort((a, b) => a.localeCompare(b)); + + const items = files + .map((file) => ({ fileName: file, name: path.parse(file).name })) + .filter((s) => (q ? s.name.toLowerCase().includes(q) : true)); + + res.json(items); +}); + +app.get('/api/channels', (_req: Request, res: Response) => { + if (!client.isReady()) return res.status(503).json({ error: 'Bot noch nicht bereit' }); + + const allowed = new Set(ALLOWED_GUILD_IDS); + const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string }> = []; + for (const [, guild] of client.guilds.cache) { + if (allowed.size > 0 && !allowed.has(guild.id)) continue; + const channels = guild.channels.cache; + for (const [, ch] of channels) { + if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) { + result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name }); + } + } + } + result.sort((a, b) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName)); + res.json(result); +}); + +app.post('/api/play', async (req: Request, res: Response) => { + try { + const { soundName, guildId, channelId } = req.body as { + soundName?: string; + guildId?: string; + channelId?: string; + }; + if (!soundName || !guildId || !channelId) return res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); + + const filePath = path.join(SOUNDS_DIR, `${soundName}.mp3`); + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Sound nicht gefunden' }); + + const guild = client.guilds.cache.get(guildId); + if (!guild) return res.status(404).json({ error: 'Guild nicht gefunden' }); + const channel = guild.channels.cache.get(channelId); + if (!channel || (channel.type !== ChannelType.GuildVoice && channel.type !== ChannelType.GuildStageVoice)) { + return res.status(400).json({ error: 'Ungültiger Voice-Channel' }); + } + + let state = guildAudioState.get(guildId); + if (!state) { + const connection = joinVoiceChannel({ + channelId, + guildId, + adapterCreator: guild.voiceAdapterCreator as any + }); + const player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); + connection.subscribe(player); + state = { connection, player }; + guildAudioState.set(guildId, state); + + state.player.on(AudioPlayerStatus.Idle, () => { + // optional: Verbindung bestehen lassen oder nach Timeout trennen + }); + } else { + // Wechsel in anderen Channel, wenn nötig + const current = getVoiceConnection(guildId); + if (current && (current.joinConfig.channelId !== channelId)) { + current.destroy(); + const connection = joinVoiceChannel({ + channelId, + guildId, + adapterCreator: guild.voiceAdapterCreator as any + }); + const player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); + connection.subscribe(player); + state = { connection, player }; + guildAudioState.set(guildId, state); + } + } + + const resource = createAudioResource(filePath); + state.player.stop(); + state.player.play(resource); + return res.json({ ok: true }); + } catch (err: any) { + console.error('Play-Fehler:', err); + return res.status(500).json({ error: err?.message ?? 'Unbekannter Fehler' }); + } +}); + +// Static Frontend ausliefern (Vite build) +const webDistPath = path.resolve(__dirname, '../../web/dist'); +if (fs.existsSync(webDistPath)) { + app.use(express.static(webDistPath)); + app.get('*', (_req, res) => { + res.sendFile(path.join(webDistPath, 'index.html')); + }); +} + +app.listen(PORT, () => { + console.log(`Server läuft auf http://0.0.0.0:${PORT}`); +}); + + + + diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..440b8b5 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Node", + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..8b9951f --- /dev/null +++ b/web/index.html @@ -0,0 +1,17 @@ + + + + + + Discord Soundboard + + +
+ + + + + + + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..6b3a4ef --- /dev/null +++ b/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "discord-soundboard-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "typescript": "^5.5.4", + "vite": "^5.3.4" + } +} + + diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..d56cda4 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,5 @@ +lockfileVersion: '9.0' + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..7a8302e --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { fetchChannels, fetchSounds, playSound } from './api'; +import type { VoiceChannelInfo, Sound } from './types'; + +export default function App() { + const [sounds, setSounds] = useState([]); + const [channels, setChannels] = useState([]); + const [query, setQuery] = useState(''); + const [selected, setSelected] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + try { + const [s, c] = await Promise.all([fetchSounds(), fetchChannels()]); + setSounds(s); + setChannels(c); + if (c[0]) setSelected(`${c[0].guildId}:${c[0].channelId}`); + } catch (e: any) { + setError(e?.message || 'Fehler beim Laden'); + } + })(); + + const interval = setInterval(async () => { + try { + const s = await fetchSounds(query); + setSounds(s); + } catch {} + }, 10000); + return () => clearInterval(interval); + }, []); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return sounds; + return sounds.filter((s) => s.name.toLowerCase().includes(q)); + }, [sounds, query]); + + async function handlePlay(name: string) { + setError(null); + if (!selected) return setError('Bitte einen Voice-Channel auswählen'); + const [guildId, channelId] = selected.split(':'); + try { + setLoading(true); + await playSound(name, guildId, channelId); + } catch (e: any) { + setError(e?.message || 'Play fehlgeschlagen'); + } finally { + setLoading(false); + } + } + + return ( +
+
+

Discord Soundboard

+

Schicke dem Bot per privater Nachricht eine .mp3 — neue Sounds erscheinen automatisch.

+
+ +
+ setQuery(e.target.value)} + placeholder="Nach Sounds suchen..." + aria-label="Suche" + /> + +
+ + {error &&
{error}
} + +
+ {filtered.map((s) => ( + + ))} + {filtered.length === 0 &&
Keine Sounds gefunden.
} +
+
+ ); +} + + + + + diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..3a808ea --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,34 @@ +import type { Sound, VoiceChannelInfo } from './types'; + +const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; + +export async function fetchSounds(q?: string): Promise { + const url = new URL(`${API_BASE}/sounds`, window.location.origin); + if (q) url.searchParams.set('q', q); + const res = await fetch(url.toString()); + if (!res.ok) throw new Error('Fehler beim Laden der Sounds'); + return res.json(); +} + +export async function fetchChannels(): Promise { + const res = await fetch(`${API_BASE}/channels`); + if (!res.ok) throw new Error('Fehler beim Laden der Channels'); + return res.json(); +} + +export async function playSound(soundName: string, guildId: string, channelId: string): Promise { + const res = await fetch(`${API_BASE}/play`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ soundName, guildId, channelId }) + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || 'Play fehlgeschlagen'); + } +} + + + + + diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..f9ba332 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './styles.css'; + +const container = document.getElementById('root')!; +createRoot(container).render(); + + + + + diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..0321262 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,24 @@ +:root { color-scheme: dark light; } +* { box-sizing: border-box; } +body { margin: 0; font-family: ui-sans-serif, system-ui, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; background: linear-gradient(120deg, #1e3c72, #2a5298); min-height: 100vh; color: #111; } + +.container { max-width: 1000px; margin: 0 auto; padding: 24px; } +header { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; } +header h1 { margin: 0; } + +.controls { display: flex; gap: 12px; margin-bottom: 16px; } +.controls input { flex: 1; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,.3); background: rgba(255,255,255,.9); } +.controls select { padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,.3); background: rgba(255,255,255,.9); } + +.error { background: #ffefef; color: #900; border: 1px solid #fcc; padding: 10px 12px; border-radius: 8px; margin-bottom: 12px; } + +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; } +.sound { padding: 18px; border-radius: 14px; border: 0; background: linear-gradient(135deg, #ff8a00, #e52e71); color: #fff; cursor: pointer; font-weight: 700; box-shadow: 0 6px 20px rgba(0,0,0,.2); letter-spacing: .2px; } +.sound:hover { filter: brightness(1.05); } +.sound:disabled { opacity: 0.6; cursor: not-allowed; } + +.hint { opacity: .7; padding: 24px 0; } + + + + diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..03fa0a1 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,16 @@ +export type Sound = { + fileName: string; + name: string; +}; + +export type VoiceChannelInfo = { + guildId: string; + guildName: string; + channelId: string; + channelName: string; +}; + + + + + diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..bf28c30 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true + }, + "include": ["src"] +} + + + + + diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..771be06 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true + } + } + } +}); + + + + +