diff --git a/.env.example b/.env.example
index 2d3a7d2..43dc3e1 100644
--- a/.env.example
+++ b/.env.example
@@ -1,20 +1,28 @@
-# Backend
+# Music App Environment Configuration
+
+# Database
DATABASE_URL=sqlite:///./app.db
+
+# Music directory for scanning
MUSIC_DIR=./music
+
+# Upload directory
UPLOAD_DIR=./uploads
+
+# Static files directory
STATIC_DIR=./static
-TRANSCODE_FORMAT=ogg
-TRANSCODE_BITRATE=192k
-# APIs
-GENIUS_API_KEY=your_genius_api_key
-RADIO_BROWSER_INSTANCE=https://de1.api.radio-browser.info
-MUSICBRAINZ_USER_AGENT=MusicApp/1.0
+# CORS origins (comma-separated)
+CORS_ORIGINS=http://localhost:5173,http://localhost:3000
-# Server
-BACKEND_HOST=0.0.0.0
-BACKEND_PORT=8000
-CORS_ORIGINS=http://localhost:5173,http://localhost:8081
+# API Key for protecting API routes (set a strong value in production)
+API_KEY=your-secret-api-key-here
-# LoFi Streams
-LOFI_GIRL_URL=https://www.youtube.com/live/jfKfPfyJRdk
\ No newline at end of file
+# Genius API key for lyrics fetching (get one at https://genius.com/api)
+GENIUS_API_KEY=your-genius-api-key-here
+
+# System dependencies:
+# - ffmpeg: required for audio transcoding (OGG conversion) and metadata extraction
+# Ubuntu/Debian: apt-get install ffmpeg
+# macOS: brew install ffmpeg
+# Windows: choco install ffmpeg or download from https://ffmpeg.org/download.html
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..818e89c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,37 @@
+# Music App
+
+Self-hosted music streaming application with mood radio, lofi channels, SharePlay, and internet radio.
+
+## Quick Start
+
+```bash
+docker-compose up --build
+```
+
+## System Dependencies
+
+- **ffmpeg** — required for audio transcoding (OGG conversion) and metadata extraction
+ - Debian/Ubuntu: `sudo apt-get install ffmpeg`
+ - macOS: `brew install ffmpeg`
+ - Windows: download from https://ffmpeg.org/download.html
+
+## Configuration
+
+Copy `.env.example` to `.env` and set your values:
+
+```bash
+cp .env.example .env
+```
+
+Key settings:
+- `API_KEY` — API key for authentication (required in production)
+- `GENIUS_API_KEY` — Genius.com API key for lyrics (get at https://genius.com/api)
+- `MUSIC_DIR` — path to local music files
+- `DATABASE_URL` — database connection string
+
+## Project Structure
+
+- `backend/` — FastAPI backend (Python)
+- `web/` — React web frontend (Vite + Tailwind)
+- `mobile/` — React Native mobile app (Expo)
+- `shared/` — Shared TypeScript types and API client
diff --git a/backend/app/main.py b/backend/app/main.py
index eb3a5c0..b89b412 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,6 +1,9 @@
-from fastapi import FastAPI
+import os
+import functools
+from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
+from starlette.middleware.base import BaseHTTPMiddleware
import os
from .db.database import init_db
@@ -31,6 +34,33 @@ app.add_middleware(
allow_headers=["*"],
)
+# Auth middleware
+API_KEY = os.getenv("API_KEY", "")
+
+
+class APIKeyMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request: Request, call_next):
+ if not API_KEY:
+ return await call_next(request)
+ path = request.url.path
+ if path in ("/health", "/static") or path.startswith("/static"):
+ return await call_next(request)
+ header_key = request.headers.get("x-api-key", "")
+ query_key = request.query_params.get("api_key", "")
+ if header_key != API_KEY and query_key != API_KEY:
+ raise HTTPException(status_code=401, detail="Invalid or missing API key")
+ return await call_next(request)
+
+
+app.add_middleware(APIKeyMiddleware)
+
+
+def require_api_key(api_key: str = Depends(lambda: None)):
+ if not API_KEY:
+ return
+ raise HTTPException(status_code=401, detail="API key required")
+
+
# Static files
static_dir = os.getenv("STATIC_DIR", "./static")
os.makedirs(static_dir, exist_ok=True)
@@ -54,8 +84,16 @@ app.include_router(account.router)
@app.on_event("startup")
def startup():
init_db()
+ from sqlalchemy.orm import Session
+ from .db.database import SessionLocal
+ db = SessionLocal()
+ try:
+ from .routers.lofi import _seed_channels
+ _seed_channels(db)
+ finally:
+ db.close()
@app.get("/health")
def health_check():
- return {"status": "ok"}
\ No newline at end of file
+ return {"status": "ok"}
diff --git a/backend/app/routers/lofi.py b/backend/app/routers/lofi.py
index f7ba199..dbf1abb 100644
--- a/backend/app/routers/lofi.py
+++ b/backend/app/routers/lofi.py
@@ -25,7 +25,6 @@ DEFAULT_CHANNELS = [
@router.get("/channels", response_model=List[LofiChannelResponse])
def list_channels(db: Session = Depends(get_db)):
- _seed_channels(db)
channels = db.query(LofiChannel).filter(LofiChannel.is_active == True).all()
return [LofiChannelResponse.model_validate(c) for c in channels]
@@ -59,4 +58,4 @@ def _seed_channels(db: Session):
if not existing:
channel = LofiChannel(**ch)
db.add(channel)
- db.commit()
\ No newline at end of file
+ db.commit()
diff --git a/backend/app/routers/shareplay.py b/backend/app/routers/shareplay.py
index 8522d16..31f3c04 100644
--- a/backend/app/routers/shareplay.py
+++ b/backend/app/routers/shareplay.py
@@ -21,6 +21,9 @@ class ControlRequest(BaseModel):
router = APIRouter(prefix="/api/shareplay", tags=["shareplay"])
manager = SharePlayManager()
+# Track active WebSocket connections per room
+active_connections: dict[str, list[WebSocket]] = {}
+
@router.post("/create")
def create_room(db: Session = Depends(get_db)):
@@ -80,25 +83,81 @@ def send_control(data: ControlRequest, db: Session = Depends(get_db)):
@router.websocket("/ws/{room_id}")
async def websocket_endpoint(websocket: WebSocket, room_id: str):
await websocket.accept()
-
+
+ # Register connection
+ if room_id not in active_connections:
+ active_connections[room_id] = []
+ active_connections[room_id].append(websocket)
+
+ # Send initial state
state = manager.get_state(room_id)
if state:
await websocket.send_json({"type": "state", "data": state})
-
+
try:
while True:
data = await websocket.receive_text()
import json
message = json.loads(data)
-
+
if message.get("type") == "control":
cmd = message.get("payload", {})
state = manager.get_state(room_id)
if state and cmd.get("type") == "play":
manager.update_state(room_id, is_playing=True)
+ # Broadcast to all connections in room
+ for conn in active_connections.get(room_id, []):
+ try:
+ await conn.send_json({
+ "type": "playback_update",
+ "data": {"is_playing": True}
+ })
+ except:
+ pass
elif state and cmd.get("type") == "pause":
manager.update_state(room_id, is_playing=False)
-
+ for conn in active_connections.get(room_id, []):
+ try:
+ await conn.send_json({
+ "type": "playback_update",
+ "data": {"is_playing": False}
+ })
+ except:
+ pass
+
await websocket.send_json({"type": "ack", "data": message})
+
+ elif message.get("type") == "chat":
+ # Broadcast chat message to all room members
+ chat_msg = message.get("payload", {})
+ for conn in active_connections.get(room_id, []):
+ try:
+ await conn.send_json({
+ "type": "chat",
+ "data": chat_msg
+ })
+ except:
+ pass
+
+ elif message.get("type") == "seek":
+ pos = message.get("payload", {}).get("position", 0)
+ manager.update_state(room_id, position=pos)
+ for conn in active_connections.get(room_id, []):
+ try:
+ await conn.send_json({
+ "type": "seek_update",
+ "data": {"position": pos}
+ })
+ except:
+ pass
+
except WebSocketDisconnect:
- pass
\ No newline at end of file
+ # Clean up disconnected client
+ if room_id in active_connections:
+ try:
+ active_connections[room_id].remove(websocket)
+ except ValueError:
+ pass
+ # Remove room entry if no connections left
+ if not active_connections[room_id]:
+ del active_connections[room_id]
diff --git a/backend/app/routers/songs.py b/backend/app/routers/songs.py
index 909f82d..1c174d6 100644
--- a/backend/app/routers/songs.py
+++ b/backend/app/routers/songs.py
@@ -114,9 +114,16 @@ async def upload_song(file: UploadFile = File(...), db: Session = Depends(get_db
saved_filename = f"{file_id}_{uuid.uuid4().hex[:8]}{ext}"
file_path = os.path.join(upload_dir, saved_filename)
+ # Stream file in chunks instead of loading entire file into memory
+ file_size = 0
+ chunk_size = 8192
async with aiofiles.open(file_path, "wb") as f:
- content = await file.read()
- await f.write(content)
+ while True:
+ chunk = await file.read(chunk_size)
+ if not chunk:
+ break
+ await f.write(chunk)
+ file_size += len(chunk)
metadata = extract_metadata(file_path)
transcoded_path = transcode_to_ogg(file_path, upload_dir)
@@ -132,7 +139,7 @@ async def upload_song(file: UploadFile = File(...), db: Session = Depends(get_db
transcoded_path=transcoded_path,
album_art_path=metadata.get("album_art_path"),
file_format=ext.replace(".", ""),
- file_size_bytes=len(content),
+ file_size_bytes=file_size,
)
db.add(song)
@@ -156,4 +163,4 @@ def delete_song(song_id: str, db: Session = Depends(get_db)):
delete_song_service(song)
db.delete(song)
db.commit()
- return {"message": "Song deleted"}
\ No newline at end of file
+ return {"message": "Song deleted"}
diff --git a/mobile/app/library.tsx b/mobile/app/library.tsx
index 7eba930..3347ed4 100644
--- a/mobile/app/library.tsx
+++ b/mobile/app/library.tsx
@@ -1,19 +1,31 @@
-import { View, Text, ScrollView, TouchableOpacity, TextInput } from 'react-native'
+import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar'
-import { useState } from 'react'
-
-const PLAYLISTS = [
- { id: '1', name: 'Chill Vibes' },
- { id: '2', name: 'Workout Mix' },
- { id: '3', name: 'Road Trip' },
- { id: '4', name: 'Late Night' },
-]
+import { useState, useEffect } from 'react'
export default function LibraryScreen() {
const router = useRouter()
const [search, setSearch] = useState('')
- const filtered = PLAYLISTS.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
+ const [playlists, setPlaylists] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ fetchPlaylists()
+ }, [])
+
+ const fetchPlaylists = async () => {
+ try {
+ const res = await fetch('/api/playlists')
+ const data = await res.json()
+ setPlaylists(data.items || data || [])
+ } catch {
+ setPlaylists([])
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const filtered = playlists.filter(p => p.name?.toLowerCase().includes(search.toLowerCase()))
return (
@@ -35,16 +47,22 @@ export default function LibraryScreen() {
-
- {filtered.map((playlist) => (
- router.push(`/playlist?id=${playlist.id}` as any)} className="items-center">
-
- {playlist.name}
-
- ))}
-
+ {loading ? (
+
+
+
+ ) : (
+
+ {filtered.map((playlist: any) => (
+ router.push(`/playlist?id=${playlist.id}` as any)} className="items-center">
+
+ {playlist.name}
+
+ ))}
+
+ )}
)
-}
\ No newline at end of file
+}
diff --git a/mobile/app/lofi.tsx b/mobile/app/lofi.tsx
index 83cda48..2145c65 100644
--- a/mobile/app/lofi.tsx
+++ b/mobile/app/lofi.tsx
@@ -1,15 +1,29 @@
-import { View, Text, ScrollView, TouchableOpacity } from 'react-native'
+import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar'
-
-const CHANNELS = [
- { id: '1', name: 'Lofi Girl - beats to relax/study to' },
- { id: '2', name: 'Chillhop Radio' },
- { id: '3', name: 'Lofi Hip Hop' },
-]
+import { usePlayerStore } from '../src/store/playerStore'
+import { useState, useEffect } from 'react'
export default function LofiScreen() {
const router = useRouter()
+ const [channels, setChannels] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ fetchChannels()
+ }, [])
+
+ const fetchChannels = async () => {
+ try {
+ const res = await fetch('/api/lofi/channels')
+ const data = await res.json()
+ setChannels(data || [])
+ } catch {
+ setChannels([])
+ } finally {
+ setLoading(false)
+ }
+ }
return (
@@ -18,22 +32,37 @@ export default function LofiScreen() {
router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
👤
- LoFi Channel
+ LoFi Channels
- {CHANNELS.map((ch) => (
-
-
- 🎵
-
-
- LoFi
- {ch.name}
-
-
- ))}
+
+ {loading ? (
+
+
+
+ ) : (
+ channels.map((ch: any) => (
+
+
+ 🎵
+
+ LoFi
+ {ch.name}
+ {ch.description}
+
+
+
+ ))
+ )}
+
+ {channels.length === 0 && !loading && (
+
+ 🌙
+ No channels available
+
+ )}
)
-}
\ No newline at end of file
+}
diff --git a/mobile/app/playlist.tsx b/mobile/app/playlist.tsx
index 2d18767..eb08ff0 100644
--- a/mobile/app/playlist.tsx
+++ b/mobile/app/playlist.tsx
@@ -1,28 +1,38 @@
-import { View, Text, ScrollView, TouchableOpacity, FlatList } from 'react-native'
+import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router'
import { usePlayerStore } from '../src/store/playerStore'
import { BottomNavBar } from '../src/components/BottomNavBar'
-
-const SONGS = [
- { id: '1', title: 'Song One', duration: 234 },
- { id: '2', title: 'Song Two', duration: 198 },
- { id: '3', title: 'Song Three', duration: 267 },
- { id: '4', title: 'Song Four', duration: 312 },
- { id: '5', title: 'Song Five', duration: 189 },
-]
-
-const PLAYLISTS = [
- { id: '1', name: 'Chill Vibes' },
- { id: '2', name: 'Workout' },
- { id: '3', name: 'Road Trip' },
- { id: '4', name: 'Late Night' },
- { id: '5', name: 'Focus' },
-]
+import { useState, useEffect } from 'react'
export default function PlaylistScreen() {
const router = useRouter()
const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay)
+ const [songs, setSongs] = useState([])
+ const [playlists, setPlaylists] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ fetchData()
+ }, [])
+
+ const fetchData = async () => {
+ try {
+ const [plRes, songRes] = await Promise.all([
+ fetch('/api/playlists'),
+ fetch('/api/songs?page=1&per_page=50'),
+ ])
+ const plData = await plRes.json()
+ const songData = await songRes.json()
+ setPlaylists(plData.items || plData || [])
+ setSongs(songData.items || [])
+ } catch {
+ setPlaylists([])
+ setSongs([])
+ } finally {
+ setLoading(false)
+ }
+ }
return (
@@ -30,8 +40,8 @@ export default function PlaylistScreen() {
router.push('/library' as any)}>
←
- {PLAYLISTS.map((pl, i) => (
-
+ {playlists.map((pl: any, i: number) => (
+
))}
@@ -44,18 +54,32 @@ export default function PlaylistScreen() {
- Chill Vibes
+ {playlists[0]?.name || 'Playlist'}
📢
- {SONGS.map((song) => (
-
-
- {song.title}
- {Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
-
- ))}
+
+ {loading ? (
+
+ ) : (
+ songs.map((song: any) => (
+ usePlayerStore.getState().setSong(song)}
+ >
+
+ 🎵
+
+ {song.title}
+ {song.artist}
+
+ {song.duration_sec ? `${Math.floor(song.duration_sec / 60)}:${(song.duration_sec % 60).toString().padStart(2, '0')}` : '--:--'}
+
+
+ ))
+ )}
)
-}
\ No newline at end of file
+}
diff --git a/mobile/app/radio.tsx b/mobile/app/radio.tsx
index 7112974..0945909 100644
--- a/mobile/app/radio.tsx
+++ b/mobile/app/radio.tsx
@@ -1,9 +1,31 @@
-import { View, Text, ScrollView, TouchableOpacity, TextInput } from 'react-native'
+import { View, Text, ScrollView, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar'
+import { useState, useEffect } from 'react'
export default function RadioScreen() {
const router = useRouter()
+ const [stations, setStations] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [search, setSearch] = useState('')
+
+ useEffect(() => {
+ fetchStations()
+ }, [])
+
+ const fetchStations = async () => {
+ try {
+ const res = await fetch('/api/radio/stations?limit=20')
+ const data = await res.json()
+ setStations(data.items || data || [])
+ } catch {
+ setStations([])
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const filtered = stations.filter(s => s.name?.toLowerCase().includes(search.toLowerCase()))
return (
@@ -18,26 +40,53 @@ export default function RadioScreen() {
🔍
-
+
-
- Currently Airing Near You
-
- 🗺️
+ {loading ? (
+
+
+ Loading stations...
-
-
-
- {[1, 2, 3, 4, 5, 6].map((i) => (
-
-
- Station {i}
+ ) : (
+ <>
+
+ Browse Stations
+
+ {filtered.map((station: any) => (
+
+
+ 📻
+
+
+ {station.name || 'Station'}
+
+
+ {station.country_code || 'Local'}
+
+
+ ))}
+
- ))}
-
+ {filtered.length === 0 && (
+
+ 📻
+ No stations found
+
+ )}
+ >
+ )}
)
-}
\ No newline at end of file
+}
diff --git a/mobile/app/releases.tsx b/mobile/app/releases.tsx
index 2e0e8c9..f857133 100644
--- a/mobile/app/releases.tsx
+++ b/mobile/app/releases.tsx
@@ -1,9 +1,29 @@
-import { View, Text, ScrollView, TouchableOpacity } from 'react-native'
+import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar'
+import { usePlayerStore } from '../src/store/playerStore'
+import { useState, useEffect } from 'react'
export default function ReleasesScreen() {
const router = useRouter()
+ const [releases, setReleases] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ fetchReleases()
+ }, [])
+
+ const fetchReleases = async () => {
+ try {
+ const res = await fetch('/api/releases?limit=20')
+ const data = await res.json()
+ setReleases(data.items || [])
+ } catch {
+ setReleases([])
+ } finally {
+ setLoading(false)
+ }
+ }
return (
@@ -13,24 +33,37 @@ export default function ReleasesScreen() {
New Releases
- {[1, 2, 3].map((i) => (
-
-
-
- Artist {i}
-
- ➕ Add
-
-
-
- {[...Array(6)].map((_, j) => (
-
- ))}
-
+
+ {loading ? (
+
+
+ Loading releases...
- ))}
+ ) : releases.length > 0 ? (
+ releases.map((release: any, i: number) => (
+
+
+
+ {release.artist || 'Artist'}
+
+ ➕ Add
+
+
+
+ {[...Array(Math.min(6, release.tracks || 3))].map((_, j) => (
+
+ ))}
+
+
+ ))
+ ) : (
+
+ 🎵
+ No new releases found
+
+ )}
)
-}
\ No newline at end of file
+}
diff --git a/mobile/app/search.tsx b/mobile/app/search.tsx
index 149b208..13fa54a 100644
--- a/mobile/app/search.tsx
+++ b/mobile/app/search.tsx
@@ -1,13 +1,17 @@
-import { View, Text, ScrollView, TouchableOpacity, TextInput } from 'react-native'
+import { View, Text, ScrollView, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'
import { useRouter } from 'expo-router'
import { BottomNavBar } from '../src/components/BottomNavBar'
import { usePlayerStore } from '../src/store/playerStore'
+import { useState, useEffect } from 'react'
export default function SearchScreen() {
const router = useRouter()
const currentSong = usePlayerStore(s => s.currentSong)
const isPlaying = usePlayerStore(s => s.isPlaying)
const togglePlay = usePlayerStore(s => s.togglePlay)
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [loading, setLoading] = useState(false)
const features = [
{ id: 'music', name: 'Music', icon: '🎵', route: '/library' },
@@ -15,8 +19,28 @@ export default function SearchScreen() {
{ id: 'events', name: 'Live Events', icon: '🎪', route: '/' },
{ id: 'radio', name: 'Internet Radio', icon: '📻', route: '/radio' },
{ id: 'mood', name: 'Mood Radio', icon: '😊', route: '/mood' },
+ { id: 'lofi', name: 'LoFi', icon: '🌙', route: '/lofi' },
+ { id: 'shareplay', name: 'SharePlay', icon: '📢', route: '/shareplay' },
]
+ const handleSearch = async (text: string) => {
+ setQuery(text)
+ if (!text) {
+ setResults([])
+ return
+ }
+ setLoading(true)
+ try {
+ const res = await fetch(`/api/search?q=${encodeURIComponent(text)}`)
+ const data = await res.json()
+ setResults(data.items || [])
+ } catch {
+ setResults([])
+ } finally {
+ setLoading(false)
+ }
+ }
+
return (
@@ -24,40 +48,60 @@ export default function SearchScreen() {
router.push('/account' as any)} className="w-10 h-10 rounded-full bg-music-card items-center justify-center">
👤
-
+ Search
🔍
-
+ handleSearch(query)}
+ className="text-sm text-music-text ml-2 flex-1"
+ numberOfLines={1}
+ >
+ {query || 'What music is calling to you?'}
+
-
- {features.map((f) => (
- router.push(f.route as any)} className="flex-1 min-w-[30%] p-4 rounded-xl bg-music-card items-center">
- {f.icon}
- {f.name}
-
- ))}
-
+ {loading && (
+
+
+
+ )}
- Music Suggestions
-
- {[...Array(10)].map((_, i) => (
-
- ))}
-
+ {!loading && results.length > 0 && (
+
+ Search Results
+ {results.map((item: any, i: number) => (
+
+
+ 🎵
+
+
+ {item.title || item.name}
+ {item.artist || 'Unknown'}
+
+
+ ))}
+
+ )}
- Your Library
-
- {[1, 2, 3, 4].map((i) => (
-
-
- Playlist {i}
+ {(!query || results.length === 0) && (
+ <>
+ Browse
+
+ {features.map((f) => (
+ router.push(f.route as any)} className="flex-1 min-w-[30%] p-4 rounded-xl bg-music-card items-center">
+ {f.icon}
+ {f.name}
+
+ ))}
- ))}
-
+ >
+ )}
{currentSong && (
@@ -77,4 +121,4 @@ export default function SearchScreen() {
)
-}
\ No newline at end of file
+}
diff --git a/web/Dockerfile b/web/Dockerfile
new file mode 100644
index 0000000..c27081e
--- /dev/null
+++ b/web/Dockerfile
@@ -0,0 +1,12 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+COPY package.json package-lock.json* ./
+RUN npm ci
+
+COPY . .
+
+EXPOSE 5173
+
+CMD ["npm", "run", "dev", "--", "--host"]