diff --git a/.gitignore b/.gitignore index 2643ef4..bee6920 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ build/ node_modules/ package-lock.json - +tsconfig.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae07969 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Build stage +FROM node:20-slim AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY frontend/package*.json ./frontend/ + +# Install dependencies +RUN npm ci +RUN cd frontend && npm ci + +# Copy source files +COPY . . + +# Build frontend and backend +RUN npm run build + +# Production stage +FROM alpine:edge + +RUN apk update && apk add mpv npm yt-dlp pulseaudio pulseaudio-utils + +WORKDIR /app + +# Install only production dependencies +COPY package*.json ./ +RUN npm ci --production + +# Copy built files +COPY --from=builder /app/build ./build +COPY --from=builder /app/frontend/dist ./dist/frontend + +EXPOSE 3000 +CMD ["node", "build/server.js"] diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..74872fd --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8462518 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.19.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.19.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.18", + "globals": "^15.14.0", + "typescript": "~5.7.2", + "typescript-eslint": "^8.22.0", + "vite": "^6.1.0" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..3d7ded3 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react' +import reactLogo from './assets/react.svg' +import viteLogo from '/vite.svg' +import './App.css' + +function App() { + const [count, setCount] = useState(0) + + return ( + <> +
+ + Vite logo + + + React logo + +
+

Vite + React

+
+ +

+ Edit src/App.tsx and save to test HMR +

+
+

+ Click on the Vite and React logos to learn more +

+ + ) +} + +export default App diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..6119ad9 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..358ca9b --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..124a4f7 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,10 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "composite": true + } +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..db0becc --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..9f1ae35 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true + } + } + } +}) diff --git a/package.json b/package.json index 0d9230b..2a39b0e 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "version": "1.0.0", "main": "build/server.js", "scripts": { - "build": "tsc", - "start": "tsc && node build/server.js" + "build": "tsc -b", + "dev": "concurrently \"cd frontend && npm run dev\" \"tsc -w -p src\" \"nodemon build/server.js\"", + "start": "node build/index.js" }, "author": "", "license": "ISC", @@ -14,6 +15,8 @@ "@types/express-ws": "^3.0.5", "@types/node": "^22.13.4", "@types/ws": "^8.5.14", + "concurrently": "^9.1.2", + "nodemon": "^3.1.9", "typescript": "^5.7.3" }, "dependencies": { diff --git a/src/server.ts b/src/server.ts index d8d56a3..e53a7a6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,16 +10,16 @@ const app = express(); expressWs(app); app.use(express.json()); -const router = express.Router(); +const apiRouter = express.Router(); const mediaPlayer = new MediaPlayer(); -router.get("/playlist", async (req, res) => { +apiRouter.get("/playlist", async (req, res) => { const playlist = await mediaPlayer.getPlaylist(); res.send(playlist); }); -router.post("/playlist", async (req, res) => { +apiRouter.post("/playlist", async (req, res) => { try { const { url } = req.body as PlaylistAppendRequest; await mediaPlayer.append(url); @@ -30,13 +30,13 @@ router.post("/playlist", async (req, res) => { } }); -router.delete("/playlist/:index", async (req, res) => { +apiRouter.delete("/playlist/:index", async (req, res) => { const { index } = req.params as { index: string }; await mediaPlayer.deletePlaylistItem(parseInt(index)); res.send(JSON.stringify({ success: true })); }); -router.post("/play", async (req, res) => { +apiRouter.post("/play", async (req, res) => { try { await mediaPlayer.play(); res.send(JSON.stringify({ success: true })); @@ -46,7 +46,7 @@ router.post("/play", async (req, res) => { } }); -router.post("/pause", async (req, res) => { +apiRouter.post("/pause", async (req, res) => { try { await mediaPlayer.pause(); res.send(JSON.stringify({ success: true })); @@ -56,7 +56,7 @@ router.post("/pause", async (req, res) => { } }); -router.get("/nowplaying", async (req, res) => { +apiRouter.get("/nowplaying", async (req, res) => { const nowPlaying = await mediaPlayer.getNowPlaying(); const pauseState = await mediaPlayer.getPauseState(); const volume = await mediaPlayer.getVolume(); @@ -71,13 +71,13 @@ router.get("/nowplaying", async (req, res) => { })); }); -router.post("/volume", async (req, res) => { +apiRouter.post("/volume", async (req, res) => { const { volume } = req.body as { volume: number }; await mediaPlayer.setVolume(volume); res.send(JSON.stringify({ success: true })); }); -router.ws("/events", (ws, req) => { +apiRouter.ws("/events", (ws, req) => { console.log(req.query); mediaPlayer.subscribe(ws); @@ -86,7 +86,16 @@ router.ws("/events", (ws, req) => { }); }); -app.use("/", router); +// Serve static files for React app (after building) +app.use(express.static("dist/frontend")); + +// Mount API routes under /api +app.use("/api", apiRouter); + +// Serve React app for all other routes (client-side routing) +app.get("*", (req, res) => { + res.sendFile("dist/frontend/index.html", { root: "." }); +}); app.listen(3000, () => { console.log("Server is running on port 3000"); diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 0000000..ac631e3 --- /dev/null +++ b/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "../build", + "composite": true + }, + "include": ["./**/*"] +} \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..c2b322b --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 83a69c7..6416e8d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,8 +3,8 @@ /* Visit https://aka.ms/tsconfig to read more about this file */ /* Projects */ + "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ @@ -26,7 +26,8 @@ /* Modules */ "module": "commonjs", /* Specify what module code is generated. */ - "rootDir": "./src", /* Specify the root folder within your source files. */ + "rootDir": "./src", + "outDir": "./build", // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ @@ -55,7 +56,6 @@ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./build", /* Specify an output folder for all emitted files. */ // "removeComments": true, /* Disable emitting comments. */ // "noEmit": true, /* Disable emitting files from a compilation. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ @@ -105,5 +105,12 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + }, + "include": ["src/**/*"], // Only include backend files + "exclude": ["frontend/**/*"], // Explicitly exclude frontend files + "files": [], + "references": [ + { "path": "./src" }, // Backend reference + { "path": "./frontend" } // Frontend reference + ] }