diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..3eec15e --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=http://localhost:4000 diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/apps/web/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/apps/web/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..764d8b8 --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next'; + +const config: NextConfig = { + transpilePackages: ['@musicshare/shared'], +}; + +export default config; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..a01e02d --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "@musicshare/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --port 3737", + "build": "next build", + "start": "next start --port 3737", + "typecheck": "tsc -p ." + }, + "devDependencies": { + "@types/react": "~19.2.2", + "@types/react-dom": "~19.2.2", + "typescript": "~6.0.3" + }, + "dependencies": { + "next": "^16.3.8", + "react": "19.2.3", + "react-dom": "19.2.3", + "swr": "^2.5.1", + "@musicshare/shared": "*" + } +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..6ff83e6 --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,136 @@ +:root { + --bg: #0e0f13; + --surface: #171920; + --surface-2: #20232c; + --border: #2a2e39; + --text: #eceef3; + --muted: #9097a6; + --accent: #7c5cff; + --accent-text: #fff; + --live: #3ddc84; + --danger: #ff5c6c; + --radius: 14px; + color-scheme: dark; +} + +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; +} +a { color: inherit; } +h1 { font-size: 1.6rem; margin: 0; } +h2 { font-size: 1.05rem; margin: 0 0 0.5rem; } +input, textarea, select { + width: 100%; + padding: 0.6rem 0.75rem; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font: inherit; +} +input:focus, textarea:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; } + +.container { max-width: 680px; margin: 0 auto; padding: 1.5rem 1rem 4rem; } +.stack { display: flex; flex-direction: column; gap: 1rem; } +.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } +.grow { flex: 1; min-width: 0; } +.muted { color: var(--muted); } +.error { color: var(--danger); margin: 0; } +.notice { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 0.75rem 1rem; margin: 0; } +.narrow { max-width: 420px; margin: 2rem auto; } + +.nav { + display: flex; justify-content: space-between; align-items: center; + padding: 0.85rem 1.25rem; border-bottom: 1px solid var(--border); + position: sticky; top: 0; background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(8px); z-index: 10; +} +.brand { font-weight: 800; letter-spacing: -0.02em; text-decoration: none; font-size: 1.1rem; } +.nav-links { display: flex; gap: 1rem; align-items: center; } +.nav-links a { text-decoration: none; color: var(--muted); } +.nav-links a:hover { color: var(--text); } +.nav-links a.btn-primary { color: var(--accent-text); } + +.btn { + display: inline-flex; align-items: center; justify-content: center; + padding: 0.5rem 0.95rem; border-radius: 999px; border: 1px solid var(--border); + background: var(--surface-2); color: var(--text); font: inherit; font-weight: 600; + text-decoration: none; cursor: pointer; white-space: nowrap; +} +.btn:hover { border-color: var(--muted); } +.btn:disabled { opacity: 0.6; cursor: default; } +.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } +.btn-primary:hover { border-color: var(--accent); filter: brightness(1.1); } +.btn-danger { background: var(--danger); border-color: var(--danger); color: #fff; } +.link-button { background: none; border: 0; color: var(--muted); font: inherit; cursor: pointer; padding: 0; } +.link-button:hover { color: var(--text); } + +.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; } +.card-pinned { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); } +.pin { background: none; border: 0; cursor: pointer; font-size: 1.15rem; line-height: 1; color: var(--muted); padding: 0.1rem 0.2rem; } +.pin:hover, .pin.pinned { color: #f5c542; } +.card-header { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; flex-wrap: wrap; } +.form { display: flex; flex-direction: column; gap: 0.75rem; } +.form label { display: flex; flex-direction: column; gap: 0.3rem; font-weight: 600; font-size: 0.9rem; } + +.user-chip { display: inline-flex; align-items: center; gap: 0.5rem; text-decoration: none; font-weight: 600; } +.avatar { border-radius: 50%; object-fit: cover; flex-shrink: 0; } +.avatar-empty { display: grid; place-items: center; background: var(--surface-2); color: var(--muted); font-weight: 700; } + +.status { font-size: 0.85rem; color: var(--muted); } +.status-live { color: var(--live); } +.status-live::before { + content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: var(--live); margin-right: 6px; animation: pulse 1.6s ease-in-out infinite; +} +@keyframes pulse { 50% { opacity: 0.35; } } + +.track { display: flex; gap: 1rem; align-items: flex-start; } +.artwork { width: 104px; height: 104px; border-radius: 10px; object-fit: cover; flex-shrink: 0; background: var(--surface-2); } +.artwork-empty { display: grid; place-items: center; font-size: 2rem; color: var(--muted); } +.artwork-sm { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; flex-shrink: 0; } +.track-body { min-width: 0; flex: 1; } +.track-title { font-weight: 700; font-size: 1.05rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.track-artist { color: var(--text); opacity: 0.85; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.track-actions { display: flex; flex-wrap: wrap; gap: 0.4rem 0.75rem; align-items: center; margin-top: 0.65rem; } +.service-link { font-size: 0.8rem; color: var(--muted); text-decoration: none; } +.service-link:hover { color: var(--text); text-decoration: underline; } +.service-link.inexact { opacity: 0.6; } + +.profile-header { display: flex; gap: 1rem; align-items: flex-start; flex-wrap: wrap; } +.profile-header p { margin: 0.4rem 0 0; } +.featured blockquote { margin: 0.85rem 0 0; padding-left: 0.85rem; border-left: 3px solid var(--accent); font-style: italic; } + +.search { position: relative; } +.search-results { list-style: none; margin: 0.4rem 0 0; padding: 0.35rem; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; } +.search-results li { padding: 0.35rem 0.4rem; } +.result { display: flex; gap: 0.6rem; align-items: center; background: none; border: 0; color: inherit; font: inherit; cursor: pointer; width: 100%; text-align: left; padding: 0; } +.result:hover { color: var(--accent); } +.picked { display: flex; gap: 0.75rem; align-items: center; } + +.connections { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.5rem; } +.connection { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.75rem 0; border-top: 1px solid var(--border); flex-wrap: wrap; } +.connection form input { width: 160px; } +.connection.disabled { opacity: 0.55; } +.jellyfin-form { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-top: 0.6rem; } +.jellyfin-form input[name='serverUrl'] { grid-column: 1 / -1; } +.jellyfin-form .btn { justify-self: start; } +.webhook { margin-top: 0.4rem; font-size: 0.85rem; } +.webhook summary { cursor: pointer; color: var(--muted); } +.webhook p { margin: 0.35rem 0; } +.webhook code { font-size: 0.75rem; background: var(--surface-2); padding: 0.3rem 0.5rem; border-radius: 6px; word-break: break-all; flex: 1; min-width: 0; } +.btn-small { padding: 0.25rem 0.65rem; font-size: 0.8rem; } +@media (max-width: 480px) { .jellyfin-form { grid-template-columns: 1fr; } } + +.hero { padding: 3rem 0; display: flex; flex-direction: column; gap: 1rem; } +.hero h1 { font-size: 2.4rem; letter-spacing: -0.03em; line-height: 1.1; } +.hero p { color: var(--muted); font-size: 1.1rem; margin: 0; } + +@media (max-width: 480px) { + .artwork { width: 76px; height: 76px; } + .nav-links { gap: 0.7rem; font-size: 0.9rem; } + .hero h1 { font-size: 1.9rem; } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..a23714e --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import { Nav } from '@/components/Nav'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'musicshare', + description: "See what your friends are listening to, and play it on your own service.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +