diff --git a/.gitignore b/.gitignore index ff2ca24..4fa9bb8 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ playwright-report/ .agents/ todo.md + +# agent worktrees +.worktrees/ diff --git a/package.json b/package.json index 6c64ace..252332b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "@supabase/supabase-js": "^2.118.0", "@tanstack/react-query": "^5.104.3", "@tanstack/react-virtual": "^3.14.20", + "@vercel/analytics": "^2.0.1", + "@vercel/speed-insights": "^2.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cn": "^0.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc923e5..96d2d87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,12 @@ importers: '@tanstack/react-virtual': specifier: ^3.14.20 version: 3.14.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@vercel/analytics': + specifier: ^2.0.1 + version: 2.0.1(next@16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + '@vercel/speed-insights': + specifier: ^2.0.0 + version: 2.0.0(next@16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1233,6 +1239,61 @@ packages: '@types/ws@8.18.2': resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + '@vercel/analytics@2.0.1': + resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} + peerDependencies: + '@remix-run/react': ^2 + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + nuxt: '>= 3' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + nuxt: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + + '@vercel/speed-insights@2.0.0': + resolution: {integrity: sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==} + peerDependencies: + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + nuxt: '>= 3' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + next: + optional: true + nuxt: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + '@vitejs/plugin-react@6.1.2': resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3905,6 +3966,16 @@ snapshots: dependencies: '@types/node': 20.19.43 + '@vercel/analytics@2.0.1(next@16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)': + optionalDependencies: + next: 16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + + '@vercel/speed-insights@2.0.0(next@16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)': + optionalDependencies: + next: 16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.64.1)(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 diff --git a/scripts/check-comments.mjs b/scripts/check-comments.mjs index 9f13c39..5c2882b 100644 --- a/scripts/check-comments.mjs +++ b/scripts/check-comments.mjs @@ -21,7 +21,6 @@ function isAllowed(comment) { .replace(/^\/\*+|\*+\/$|^\/\/\/?\s*/g, "") .trim(); if (trimmed.replace(/^\//, "").trim().startsWith(" r.test(trimmed)); } @@ -35,35 +34,72 @@ function walk(dir, out = []) { return out; } +function scanComments(src) { + const found = []; + let i = 0; + let line = 1; + while (i < src.length) { + const ch = src[i]; + const next = src[i + 1]; + if (ch === "\n") { + line += 1; + i += 1; + continue; + } + if (ch === '"' || ch === "'" || ch === "`") { + const quote = ch; + i += 1; + while (i < src.length) { + if (src[i] === "\\") { + i += 2; + continue; + } + if (src[i] === "\n") line += 1; + if (src[i] === quote) { + i += 1; + break; + } + i += 1; + } + continue; + } + if (ch === "/" && next === "/") { + let end = src.indexOf("\n", i); + if (end === -1) end = src.length; + found.push({ line, raw: src.slice(i, end), inline: true }); + i = end; + continue; + } + if (ch === "/" && next === "*") { + const close = src.indexOf("*/", i + 2); + const stop = close === -1 ? src.length : close + 2; + found.push({ line, raw: src.slice(i, stop), inline: false }); + line += src.slice(i, stop).split("\n").length - 1; + i = stop; + continue; + } + i += 1; + } + return found; +} + let failed = false; for (const file of walk(ROOT)) { const src = readFileSync(file, "utf8"); - // strip strings roughly - const lines = src.split("\n"); - lines.forEach((line, i) => { - const m = line.match(/\/\/\s*(.*)$/); - if (m) { - const c = m[1].trim(); + for (const comment of scanComments(src)) { + if (comment.inline) { + const c = comment.raw.replace(/^\/\/\s*/, "").trim(); if (c && !isAllowed(c)) { - // allow JSDoc-ish inline? no; flag - console.error(`${file}:${i + 1}: disallowed comment: ${c}`); + console.error(`${file}:${comment.line}: disallowed comment: ${c}`); failed = true; } + continue; } - }); - // block comments - const blockRe = /\/\*[\s\S]*?\*\//g; - let bm = blockRe.exec(src); - while (bm) { - const c = bm[0].replace(/\/\*+|\*+\//g, "").trim(); + const c = comment.raw.replace(/\/\*+|\*+\//g, "").trim(); if (c && !isAllowed(c) && !c.startsWith("*")) { - // compute line - const upTo = src.slice(0, bm.index); - const ln = upTo.split("\n").length; - console.error(`${file}:${ln}: disallowed block comment`); + console.error(`${file}:${comment.line}: disallowed block comment`); failed = true; } - bm = blockRe.exec(src); } } diff --git a/scripts/check-seo.mjs b/scripts/check-seo.mjs new file mode 100644 index 0000000..a4ada95 --- /dev/null +++ b/scripts/check-seo.mjs @@ -0,0 +1,304 @@ +#!/usr/bin/env node +import { argv, exit } from "node:process"; + +const base = (argv[2] ?? "http://localhost:3000").replace(/\/$/, ""); + +const ROUTES = [ + { path: "/", label: "home", indexable: true }, + { path: "/search", label: "search", indexable: true }, + { path: "/library", label: "library", indexable: false }, + { path: "/login", label: "login", indexable: false }, + { path: "/signup", label: "signup", indexable: false }, + { path: "/playlist/p1", label: "playlist", indexable: true }, +]; + +const INDEXABLE = ROUTES.filter((r) => r.indexable).map((r) => r.path); +const NOINDEX = ROUTES.filter((r) => !r.indexable).map((r) => r.path); + +const results = []; +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function check(name, ok, detail) { + results.push({ name, ok, detail }); +} + +async function get(path) { + const url = base + path; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const res = await fetch(url, { redirect: "manual" }); + const body = await res.text(); + return { body, status: res.status, type: res.headers.get("content-type") ?? "" }; + } catch (error) { + if (attempt === 2) return { body: "", status: 0, type: "", error: String(error) }; + await wait(250 * (attempt + 1)); + } + } + return { body: "", status: 0, type: "", error: "unreachable" }; +} + +function attribute(tag, name) { + const match = tag.match(new RegExp(`${name}\\s*=\\s*"([^"]*)"`, "i")); + return match ? match[1] : null; +} + +function tags(html, element) { + return [...html.matchAll(new RegExp(`<${element}\\s[^>]*>`, "gi"))].map((m) => m[0]); +} + +function metaContent(html, key) { + const hit = tags(html, "meta").find( + (tag) => attribute(tag, "property") === key || attribute(tag, "name") === key, + ); + return hit ? attribute(hit, "content") : null; +} + +function linkHref(html, rel) { + const hit = tags(html, "link").find((tag) => attribute(tag, "rel") === rel); + return hit ? attribute(hit, "href") : null; +} + +function pageTitle(html) { + const match = html.match(/]*>([\s\S]*?)<\/title>/i); + return match ? match[1].trim() : null; +} + +function short(value) { + if (!value) return "none"; + return value.length > 60 ? `${value.slice(0, 57)}...` : value; +} + +const pages = []; +for (const route of ROUTES) { + const page = await get(route.path); + pages.push({ ...route, ...page }); + await wait(120); +} + +for (const page of pages) { + check( + `GET ${page.path} returns 200`, + page.status === 200, + page.error ? `error ${page.error}` : `status ${page.status}`, + ); +} + +const titles = pages.map((p) => pageTitle(p.body)); +const missingTitles = pages.filter((_, i) => !titles[i]).map((p) => p.path); +check( + "every route has a ", + missingTitles.length === 0, + `missing on ${missingTitles.join(", ") || "none"}`, +); + +const duplicateTitles = titles.filter((t, i) => t && titles.indexOf(t) !== i); +check( + "titles are unique per route", + duplicateTitles.length === 0, + `duplicates: ${[...new Set(duplicateTitles)].join(" | ") || "none"}`, +); + +const descriptions = pages.map((p) => metaContent(p.body, "description")); +const missingDescriptions = pages.filter((_, i) => !descriptions[i]).map((p) => p.path); +check( + "every route has a meta description", + missingDescriptions.length === 0, + `missing on ${missingDescriptions.join(", ") || "none"}`, +); + +const duplicateDescriptions = descriptions.filter((d, i) => d && descriptions.indexOf(d) !== i); +check( + "descriptions are unique per route", + duplicateDescriptions.length === 0, + `duplicates: ${[...new Set(duplicateDescriptions)].map(short).join(" | ") || "none"}`, +); + +const shortDescriptions = pages + .filter((_, i) => descriptions[i] && descriptions[i].length < 50) + .map((p) => p.path); +check( + "descriptions are at least 50 chars", + shortDescriptions.length === 0, + `too short on ${shortDescriptions.join(", ") || "none"}`, +); + +const canonicals = pages.map((p) => linkHref(p.body, "canonical")); +const missingCanonicals = pages.filter((_, i) => !canonicals[i]).map((p) => p.path); +check( + "every route has a canonical link", + missingCanonicals.length === 0, + `missing on ${missingCanonicals.join(", ") || "none"}`, +); + +const badCanonicals = pages + .map((page, i) => ({ path: page.path, href: canonicals[i] })) + .filter((canonical) => canonical.href && !canonical.href.startsWith(base)) + .map((canonical) => `${canonical.path} -> ${canonical.href}`); +check( + "canonicals are absolute and match the served base", + badCanonicals.length === 0, + badCanonicals.length ? badCanonicals.join(", ") : `all ${canonicals.length} start with ${base}`, +); + +for (const key of ["og:title", "og:description", "og:image"]) { + const missing = pages.filter((p) => !metaContent(p.body, key)).map((p) => p.path); + check( + `every route has ${key}`, + missing.length === 0, + `missing on ${missing.join(", ") || "none"}`, + ); +} + +const missingTwitter = pages.filter((p) => !metaContent(p.body, "twitter:card")).map((p) => p.path); +check( + "every route has twitter:card", + missingTwitter.length === 0, + `missing on ${missingTwitter.join(", ") || "none"}`, +); + +const socialKeys = ["og:type", "og:site_name", "og:url", "og:locale"]; +const strippedSocial = pages + .filter((p) => socialKeys.some((key) => !metaContent(p.body, key))) + .map( + (p) => `${p.path} missing ${socialKeys.filter((key) => !metaContent(p.body, key)).join("+")}`, + ); +check( + "openGraph keeps type, site_name, url, and locale", + strippedSocial.length === 0, + strippedSocial.length ? strippedSocial.join("; ") : "present on all routes", +); + +const firstImage = pages.map((p) => metaContent(p.body, "og:image")).find(Boolean); +if (firstImage) { + const parsed = new URL(firstImage, base); + const image = await get(parsed.pathname + parsed.search); + check( + "og:image resolves with 200", + image.status === 200, + `${parsed.pathname} -> ${image.status}`, + ); + check( + "og:image is served as an image", + image.type.startsWith("image/"), + `content-type ${image.type || "none"}`, + ); +} else { + check("og:image resolves with 200", false, "no og:image found to resolve"); + check("og:image is served as an image", false, "no og:image found to resolve"); +} + +const robots = await get("/robots.txt"); +check( + "robots.txt returns 200", + robots.status === 200, + robots.error ? `error ${robots.error}` : `status ${robots.status}`, +); +check( + "robots.txt allows crawling", + /user-agent:\s*\*/i.test(robots.body) && /allow:\s*\//i.test(robots.body), + /user-agent:\s*\*/i.test(robots.body) + ? "wildcard user-agent with allow /" + : "no wildcard allow rule", +); +const sitemapLine = robots.body.split("\n").find((line) => /^\s*sitemap:/i.test(line)); +check( + "robots.txt points at a sitemap", + Boolean(sitemapLine), + sitemapLine ? sitemapLine.trim() : "no Sitemap line", +); + +const sitemap = await get("/sitemap.xml"); +check( + "sitemap.xml returns 200", + sitemap.status === 200, + sitemap.error ? `error ${sitemap.error}` : `status ${sitemap.status}`, +); +check( + "sitemap.xml is xml", + /<\?xml|<urlset/i.test(sitemap.body), + /<urlset/i.test(sitemap.body) ? "urlset present" : "no urlset element", +); + +const absolute = (path) => (path === "/" ? base : base + path); +const missingFromSitemap = INDEXABLE.filter((path) => !sitemap.body.includes(absolute(path))); +check( + "sitemap lists every indexable route", + missingFromSitemap.length === 0, + missingFromSitemap.length + ? `missing ${missingFromSitemap.join(", ")}` + : `all ${INDEXABLE.length} indexable routes listed`, +); + +const leakedInto = NOINDEX.filter((path) => sitemap.body.includes(absolute(path))); +check( + "sitemap excludes noindex routes", + leakedInto.length === 0, + leakedInto.length + ? `lists noindex ${leakedInto.join(", ")}` + : `none of ${NOINDEX.length} noindex routes listed`, +); + +const notNoindexed = pages + .filter((p) => !p.indexable && !/noindex/i.test(metaContent(p.body, "robots") ?? "")) + .map((p) => p.path); +check( + "noindex routes emit a noindex robots meta", + notNoindexed.length === 0, + notNoindexed.length + ? `not marked noindex ${notNoindexed.join(", ")}` + : `all ${NOINDEX.length} marked noindex`, +); + +const disallowRules = robots.body + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^disallow:/i.test(line)) + .map((line) => line.replace(/^disallow:\s*/i, "").trim()) + .filter(Boolean); + +const conflicted = NOINDEX.filter((path) => disallowRules.some((rule) => path.startsWith(rule))); +check( + "robots.txt does not disallow noindex routes", + conflicted.length === 0, + conflicted.length + ? `disallowed so noindex is unreadable: ${conflicted.join(", ")}` + : `no conflict across ${disallowRules.length} disallow rule(s)`, +); + +const playlist = pages.find((p) => p.label === "playlist"); +const blocks = [ + ...(playlist?.body.matchAll(/<script[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi) ?? + []), +]; +check("playlist page has JSON-LD", blocks.length > 0, `${blocks.length} block(s)`); + +let parsedType = null; +let parseError = "no JSON-LD block"; +for (const block of blocks) { + try { + const value = JSON.parse(block[1]); + if (value?.["@context"] && value?.["@type"]) { + parsedType = value["@type"]; + parseError = null; + break; + } + parseError = "block lacks @context or @type"; + } catch (error) { + parseError = `invalid JSON: ${error}`; + } +} +check( + "JSON-LD parses with @context and @type", + Boolean(parsedType), + parsedType ? `@type ${parsedType}` : parseError, +); + +const width = Math.max(...results.map((r) => r.name.length)); +let failures = 0; +for (const result of results) { + if (!result.ok) failures += 1; + console.log(`${result.ok ? "PASS" : "FAIL"} ${result.name.padEnd(width)} ${result.detail}`); +} + +console.log(`\n${results.length - failures}/${results.length} checks passed against ${base}`); +exit(failures === 0 ? 0 : 1); diff --git a/src/app/(app)/home-client.tsx b/src/app/(app)/home-client.tsx new file mode 100644 index 0000000..c37a099 --- /dev/null +++ b/src/app/(app)/home-client.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Play } from "lucide-react"; +import Image from "next/image"; +import type { Album, Song } from "@/lib/queries"; +import { useAlbums, useSongs } from "@/lib/queries"; +import { usePlayer } from "@/stores/player"; + +export default function Home() { + const play = usePlayer((s) => s.play); + const setQueue = usePlayer((s) => s.setQueue); + const { data: songs = [] } = useSongs(); + const { data: albums = [] } = useAlbums(); + + const playAlbum = (album: Album) => { + const list = album.songs + .map((id) => songs.find((song) => song.id === id)) + .filter((song): song is Song => Boolean(song)); + if (list.length === 0) return; + setQueue(list.map((song) => song.id)); + play(list[0]); + }; + + return ( + <main className="scroll-pane flex-1 overflow-y-auto p-4 sm:p-6"> + <h1 className="text-2xl font-bold">Good evening</h1> + <div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4"> + {albums.map((a, i) => ( + <button + key={a.id} + type="button" + onClick={() => playAlbum(a)} + className="motion-safe:animate-[sunao-fade-in_200ms_var(--ease-out)_both] rounded-3xl bg-[#181818] p-4 text-left transition-colors duration-150 ease-out hover:bg-[#282828]" + style={{ animationDelay: `${i * 40}ms` }} + > + <div + className="relative mb-3 aspect-square w-full overflow-hidden rounded-lg" + style={{ background: a.color }} + > + {a.artwork && ( + <Image src={a.artwork} alt="" fill sizes="200px" className="object-cover" /> + )} + </div> + <p className="truncate text-sm font-semibold">{a.title}</p> + <p className="truncate text-xs text-white/60">{a.artist}</p> + </button> + ))} + </div> + <h2 className="mt-8 text-xl font-bold">Songs</h2> + <div className="mt-3 space-y-1"> + {songs.map((s, i) => ( + <button + key={s.id} + onClick={() => play(s)} + className="flex w-full items-center gap-3 rounded px-3 py-2 text-left transition-colors duration-150 ease-out hover:bg-white/5" + > + <span className="w-6 text-right text-sm tabular-nums text-white/40">{i + 1}</span> + <div + className="relative h-10 w-10 shrink-0 overflow-hidden rounded" + style={{ background: s.color }} + > + {s.artwork && ( + <Image src={s.artwork} alt="" fill sizes="40px" className="object-cover" /> + )} + </div> + <div className="min-w-0 flex-1"> + <p className="truncate text-sm font-medium">{s.title}</p> + <p className="truncate text-xs text-white/50">{s.artist}</p> + </div> + <span className="text-xs text-white/50 tabular-nums"> + {Math.floor(s.duration / 60)}:{String(s.duration % 60).padStart(2, "0")} + </span> + <Play className="h-4 w-4 text-white/40" /> + </button> + ))} + </div> + </main> + ); +} diff --git a/src/app/(app)/library/library-client.tsx b/src/app/(app)/library/library-client.tsx new file mode 100644 index 0000000..f633bcf --- /dev/null +++ b/src/app/(app)/library/library-client.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { PlaylistForm } from "@/components/features/playlist-form"; +import { usePlaylists } from "@/lib/queries"; + +export default function LibraryPage() { + const [creating, setCreating] = useState(false); + const router = useRouter(); + const { data: playlists = [] } = usePlaylists(); + + return ( + <main className="scroll-pane flex-1 overflow-y-auto p-4 sm:p-6"> + <div className="flex items-center justify-between"> + <h1 className="text-2xl font-bold">Your Library</h1> + <button + onClick={() => setCreating(true)} + className="rounded-full bg-red-600 px-4 py-1.5 text-sm text-white transition-[background-color,transform] duration-150 ease-out hover:bg-red-700 active:scale-95" + > + + New playlist + </button> + </div> + {creating && ( + <div className="mt-4 rounded-lg border border-white/10 bg-[#181818] p-4"> + <PlaylistForm + autoFocus + onDone={() => setCreating(false)} + onCancel={() => setCreating(false)} + /> + </div> + )} + <div className="mt-6 space-y-2"> + {playlists.map((p) => ( + <button + key={p.id} + onClick={() => router.push(`/playlist/${p.id}`)} + className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors duration-150 ease-out hover:bg-white/5" + > + <div className="h-12 w-12 shrink-0 rounded" style={{ background: p.cover }} /> + <div> + <p className="text-sm font-semibold">{p.name}</p> + <p className="text-xs text-white/60"> + {p.songIds.length} songs{p.system ? " · system" : ""} + </p> + </div> + </button> + ))} + </div> + </main> + ); +} diff --git a/src/app/(app)/library/page.tsx b/src/app/(app)/library/page.tsx index f633bcf..cf0453c 100644 --- a/src/app/(app)/library/page.tsx +++ b/src/app/(app)/library/page.tsx @@ -1,52 +1,13 @@ -"use client"; +import type { Metadata } from "next"; +import LibraryPage from "./library-client"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { PlaylistForm } from "@/components/features/playlist-form"; -import { usePlaylists } from "@/lib/queries"; +export const metadata: Metadata = { + title: "Your Library", + description: "Your sunao playlists in one place. Create them, open them, play them.", + alternates: { canonical: "/library" }, + robots: { index: false, follow: true }, +}; -export default function LibraryPage() { - const [creating, setCreating] = useState(false); - const router = useRouter(); - const { data: playlists = [] } = usePlaylists(); - - return ( - <main className="scroll-pane flex-1 overflow-y-auto p-4 sm:p-6"> - <div className="flex items-center justify-between"> - <h1 className="text-2xl font-bold">Your Library</h1> - <button - onClick={() => setCreating(true)} - className="rounded-full bg-red-600 px-4 py-1.5 text-sm text-white transition-[background-color,transform] duration-150 ease-out hover:bg-red-700 active:scale-95" - > - + New playlist - </button> - </div> - {creating && ( - <div className="mt-4 rounded-lg border border-white/10 bg-[#181818] p-4"> - <PlaylistForm - autoFocus - onDone={() => setCreating(false)} - onCancel={() => setCreating(false)} - /> - </div> - )} - <div className="mt-6 space-y-2"> - {playlists.map((p) => ( - <button - key={p.id} - onClick={() => router.push(`/playlist/${p.id}`)} - className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors duration-150 ease-out hover:bg-white/5" - > - <div className="h-12 w-12 shrink-0 rounded" style={{ background: p.cover }} /> - <div> - <p className="text-sm font-semibold">{p.name}</p> - <p className="text-xs text-white/60"> - {p.songIds.length} songs{p.system ? " · system" : ""} - </p> - </div> - </button> - ))} - </div> - </main> - ); +export default function Page() { + return <LibraryPage />; } diff --git a/src/app/(app)/page.test.tsx b/src/app/(app)/page.test.tsx index c2d9530..93d3e11 100644 --- a/src/app/(app)/page.test.tsx +++ b/src/app/(app)/page.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; -import Home from "./page"; +import Home from "./home-client"; describe("Home", () => { it("renders the greeting", () => { diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index c37a099..03bf3e0 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -1,79 +1,12 @@ -"use client"; +import type { Metadata } from "next"; +import HomeClient from "./home-client"; -import { Play } from "lucide-react"; -import Image from "next/image"; -import type { Album, Song } from "@/lib/queries"; -import { useAlbums, useSongs } from "@/lib/queries"; -import { usePlayer } from "@/stores/player"; +export const metadata: Metadata = { + title: { absolute: "sunao, a Spotify-like music player" }, + description: "Stream albums and tracks from sunao, a Spotify-like music player for the web.", + alternates: { canonical: "/" }, +}; -export default function Home() { - const play = usePlayer((s) => s.play); - const setQueue = usePlayer((s) => s.setQueue); - const { data: songs = [] } = useSongs(); - const { data: albums = [] } = useAlbums(); - - const playAlbum = (album: Album) => { - const list = album.songs - .map((id) => songs.find((song) => song.id === id)) - .filter((song): song is Song => Boolean(song)); - if (list.length === 0) return; - setQueue(list.map((song) => song.id)); - play(list[0]); - }; - - return ( - <main className="scroll-pane flex-1 overflow-y-auto p-4 sm:p-6"> - <h1 className="text-2xl font-bold">Good evening</h1> - <div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4"> - {albums.map((a, i) => ( - <button - key={a.id} - type="button" - onClick={() => playAlbum(a)} - className="motion-safe:animate-[sunao-fade-in_200ms_var(--ease-out)_both] rounded-3xl bg-[#181818] p-4 text-left transition-colors duration-150 ease-out hover:bg-[#282828]" - style={{ animationDelay: `${i * 40}ms` }} - > - <div - className="relative mb-3 aspect-square w-full overflow-hidden rounded-lg" - style={{ background: a.color }} - > - {a.artwork && ( - <Image src={a.artwork} alt="" fill sizes="200px" className="object-cover" /> - )} - </div> - <p className="truncate text-sm font-semibold">{a.title}</p> - <p className="truncate text-xs text-white/60">{a.artist}</p> - </button> - ))} - </div> - <h2 className="mt-8 text-xl font-bold">Songs</h2> - <div className="mt-3 space-y-1"> - {songs.map((s, i) => ( - <button - key={s.id} - onClick={() => play(s)} - className="flex w-full items-center gap-3 rounded px-3 py-2 text-left transition-colors duration-150 ease-out hover:bg-white/5" - > - <span className="w-6 text-right text-sm tabular-nums text-white/40">{i + 1}</span> - <div - className="relative h-10 w-10 shrink-0 overflow-hidden rounded" - style={{ background: s.color }} - > - {s.artwork && ( - <Image src={s.artwork} alt="" fill sizes="40px" className="object-cover" /> - )} - </div> - <div className="min-w-0 flex-1"> - <p className="truncate text-sm font-medium">{s.title}</p> - <p className="truncate text-xs text-white/50">{s.artist}</p> - </div> - <span className="text-xs text-white/50 tabular-nums"> - {Math.floor(s.duration / 60)}:{String(s.duration % 60).padStart(2, "0")} - </span> - <Play className="h-4 w-4 text-white/40" /> - </button> - ))} - </div> - </main> - ); +export default function Page() { + return <HomeClient />; } diff --git a/src/app/(app)/playlist/[id]/page.tsx b/src/app/(app)/playlist/[id]/page.tsx index 1156db1..19b0517 100644 --- a/src/app/(app)/playlist/[id]/page.tsx +++ b/src/app/(app)/playlist/[id]/page.tsx @@ -1,115 +1,54 @@ -"use client"; - -import { ArrowLeft, Play, X } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { use, useState } from "react"; -import { EmptyState } from "@/components/ui/empty-state"; -import { usePlaylist, useSongs } from "@/lib/queries"; -import { usePlayer } from "@/stores/player"; -import type { Song } from "@/types"; - -export default function PlaylistPage({ params }: { params?: Promise<{ id: string }> }) { - const { id } = use(params ?? Promise.resolve({ id: "" })); - const router = useRouter(); - const { data: playlist, isLoading, isError } = usePlaylist(id); - const { data: allSongs = [] } = useSongs(); - const play = usePlayer((s) => s.play); - const setQueue = usePlayer((s) => s.setQueue); - const [removed, setRemoved] = useState<Set<number>>(new Set()); - - if (isLoading) { - return ( - <main className="scroll-pane flex-1 overflow-y-auto p-4 sm:p-6"> - <p className="text-white/60">Loading...</p> - </main> - ); - } - - if (isError || !playlist) { - return ( - <main className="scroll-pane flex-1 overflow-y-auto p-4 sm:p-6"> - <button - onClick={() => router.back()} - className="mb-4 inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-white/70 transition-colors duration-150 ease-out hover:bg-white/5 hover:text-white" - > - <ArrowLeft className="h-4 w-4" /> Back - </button> - <p className="text-lg font-semibold text-red-500">Playlist not found</p> - </main> - ); - } - - const songIds = playlist.songIds.filter((sid) => !removed.has(sid)); - const songs = songIds - .map((sid) => allSongs.find((s) => s.id === sid)) - .filter((s): s is Song => Boolean(s)); +import type { Metadata, ResolvingMetadata } from "next"; +import { notFound } from "next/navigation"; +import { PLAYLISTS } from "@/lib/data/songs"; +import { SITE_URL } from "@/lib/site"; +import PlaylistPage from "./playlist-client"; + +export function generateStaticParams() { + return PLAYLISTS.map((p) => ({ id: p.id })); +} - const playAll = () => { - if (songs.length === 0) return; - setQueue(songs.map((s) => s.id)); - play(songs[0]); +export async function generateMetadata( + { params }: { params: Promise<{ id: string }> }, + parent: ResolvingMetadata, +): Promise<Metadata> { + const { id } = await params; + const playlist = PLAYLISTS.find((p) => p.id === id); + if (!playlist) return { title: "Playlist not found", robots: { index: false, follow: false } }; + const n = playlist.songIds.length; + const description = `${playlist.name} playlist, ${n} tracks. Play the full list on sunao.`; + const { openGraph: inherited } = await parent; + return { + title: playlist.name, + description, + alternates: { canonical: `/playlist/${id}` }, + openGraph: { + ...inherited, + title: playlist.name, + description, + url: `/playlist/${id}`, + images: inherited?.images, + }, }; +} - const remove = (songId: number) => { - setRemoved((prev) => { - const next = new Set(prev); - next.add(songId); - return next; - }); +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const playlist = PLAYLISTS.find((p) => p.id === id); + if (!playlist) notFound(); + const jsonLd = { + "@context": "https://schema.org", + "@type": "MusicPlaylist", + name: playlist.name, + numTracks: playlist.songIds.length, + url: `${SITE_URL}/playlist/${id}`, }; - + const jsonLdScript = { __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }; return ( - <main className="scroll-pane flex-1 overflow-y-auto p-4 sm:p-6"> - <button - onClick={() => router.back()} - className="mb-4 inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-white/70 transition-colors duration-150 ease-out hover:bg-white/5 hover:text-white" - > - <ArrowLeft className="h-4 w-4" /> Back - </button> - <div className="flex items-center gap-6"> - <div className="h-40 w-40 shrink-0 rounded-lg" style={{ background: playlist.cover }} /> - <div> - <h1 className="text-3xl font-bold">{playlist.name}</h1> - <p className="mt-1 text-sm text-white/60">{songs.length} songs</p> - <button - onClick={playAll} - className="mt-4 inline-flex items-center gap-2 rounded-full bg-red-600 px-6 py-2 text-sm font-semibold text-white transition-[background-color,transform] duration-150 ease-out hover:bg-red-700 active:scale-95" - > - <Play className="h-4 w-4" /> Play all - </button> - </div> - </div> - {songs.length === 0 ? ( - <EmptyState title="Empty playlist" description="Add songs to get started." /> - ) : ( - <div className="mt-8 space-y-1"> - {songs.map((s, i) => ( - <div - key={s.id} - className="group/row flex w-full items-center gap-3 rounded px-3 py-2 transition-colors duration-150 ease-out hover:bg-white/5" - > - <button onClick={() => play(s)} className="flex flex-1 items-center gap-3 text-left"> - <span className="w-6 text-right text-sm tabular-nums text-white/40">{i + 1}</span> - <div className="h-10 w-10 shrink-0 rounded" style={{ background: s.color }} /> - <div className="min-w-0 flex-1"> - <p className="truncate text-sm font-medium">{s.title}</p> - <p className="truncate text-xs text-white/50">{s.artist}</p> - </div> - <span className="text-xs text-white/50 tabular-nums"> - {Math.floor(s.duration / 60)}:{String(s.duration % 60).padStart(2, "0")} - </span> - </button> - <button - onClick={() => remove(s.id)} - aria-label="Remove" - className="flex h-8 w-8 items-center justify-center rounded-full text-white/40 opacity-0 transition-[color,background-color,opacity,transform] duration-150 ease-out group-hover/row:opacity-100 focus-visible:opacity-100 hover:bg-white/10 hover:text-red-500 active:scale-90" - > - <X className="h-4 w-4" /> - </button> - </div> - ))} - </div> - )} - </main> + <> + {/* biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD, < scrubbed to \u003c */} + <script type="application/ld+json" dangerouslySetInnerHTML={jsonLdScript} /> + <PlaylistPage id={id} /> + </> ); } diff --git a/src/app/(app)/playlist/[id]/playlist-client.tsx b/src/app/(app)/playlist/[id]/playlist-client.tsx new file mode 100644 index 0000000..764cb03 --- /dev/null +++ b/src/app/(app)/playlist/[id]/playlist-client.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { ArrowLeft, Play, X } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { EmptyState } from "@/components/ui/empty-state"; +import { usePlaylist, useSongs } from "@/lib/queries"; +import { usePlayer } from "@/stores/player"; +import type { Song } from "@/types"; + +export default function PlaylistPage({ id }: { id: string }) { + const router = useRouter(); + const { data: playlist, isLoading, isError } = usePlaylist(id); + const { data: allSongs = [] } = useSongs(); + const play = usePlayer((s) => s.play); + const setQueue = usePlayer((s) => s.setQueue); + const [removed, setRemoved] = useState<Set<number>>(new Set()); + + if (isLoading) { + return ( + <main className="scroll-pane flex-1 overflow-y-auto p-4 sm:p-6"> + <p className="text-white/60">Loading...</p> + </main> + ); + } + + if (isError || !playlist) { + return ( + <main className="scroll-pane flex-1 overflow-y-auto p-4 sm:p-6"> + <button + onClick={() => router.back()} + className="mb-4 inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-white/70 transition-colors duration-150 ease-out hover:bg-white/5 hover:text-white" + > + <ArrowLeft className="h-4 w-4" /> Back + </button> + <p className="text-lg font-semibold text-red-500">Playlist not found</p> + </main> + ); + } + + const songIds = playlist.songIds.filter((sid) => !removed.has(sid)); + const songs = songIds + .map((sid) => allSongs.find((s) => s.id === sid)) + .filter((s): s is Song => Boolean(s)); + + const playAll = () => { + if (songs.length === 0) return; + setQueue(songs.map((s) => s.id)); + play(songs[0]); + }; + + const remove = (songId: number) => { + setRemoved((prev) => { + const next = new Set(prev); + next.add(songId); + return next; + }); + }; + + return ( + <main className="scroll-pane flex-1 overflow-y-auto p-4 sm:p-6"> + <button + onClick={() => router.back()} + className="mb-4 inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-white/70 transition-colors duration-150 ease-out hover:bg-white/5 hover:text-white" + > + <ArrowLeft className="h-4 w-4" /> Back + </button> + <div className="flex items-center gap-6"> + <div className="h-40 w-40 shrink-0 rounded-lg" style={{ background: playlist.cover }} /> + <div> + <h1 className="text-3xl font-bold">{playlist.name}</h1> + <p className="mt-1 text-sm text-white/60">{songs.length} songs</p> + <button + onClick={playAll} + className="mt-4 inline-flex items-center gap-2 rounded-full bg-red-600 px-6 py-2 text-sm font-semibold text-white transition-[background-color,transform] duration-150 ease-out hover:bg-red-700 active:scale-95" + > + <Play className="h-4 w-4" /> Play all + </button> + </div> + </div> + {songs.length === 0 ? ( + <EmptyState title="Empty playlist" description="Add songs to get started." /> + ) : ( + <div className="mt-8 space-y-1"> + {songs.map((s, i) => ( + <div + key={s.id} + className="group/row flex w-full items-center gap-3 rounded px-3 py-2 transition-colors duration-150 ease-out hover:bg-white/5" + > + <button onClick={() => play(s)} className="flex flex-1 items-center gap-3 text-left"> + <span className="w-6 text-right text-sm tabular-nums text-white/40">{i + 1}</span> + <div className="h-10 w-10 shrink-0 rounded" style={{ background: s.color }} /> + <div className="min-w-0 flex-1"> + <p className="truncate text-sm font-medium">{s.title}</p> + <p className="truncate text-xs text-white/50">{s.artist}</p> + </div> + <span className="text-xs text-white/50 tabular-nums"> + {Math.floor(s.duration / 60)}:{String(s.duration % 60).padStart(2, "0")} + </span> + </button> + <button + onClick={() => remove(s.id)} + aria-label="Remove" + className="flex h-8 w-8 items-center justify-center rounded-full text-white/40 opacity-0 transition-[color,background-color,opacity,transform] duration-150 ease-out group-hover/row:opacity-100 focus-visible:opacity-100 hover:bg-white/10 hover:text-red-500 active:scale-90" + > + <X className="h-4 w-4" /> + </button> + </div> + ))} + </div> + )} + </main> + ); +} diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx index e36dc73..b0754cc 100644 --- a/src/app/(app)/search/page.tsx +++ b/src/app/(app)/search/page.tsx @@ -1,60 +1,12 @@ -"use client"; +import type { Metadata } from "next"; +import SearchPage from "./search-client"; -import { Play, Search as SearchIcon } from "lucide-react"; -import { useState } from "react"; -import { EmptyState } from "@/components/ui/empty-state"; -import { useSongs } from "@/lib/queries"; -import { usePlayer } from "@/stores/player"; +export const metadata: Metadata = { + title: "Search", + description: "Search the sunao catalog by song or artist and start playing instantly.", + alternates: { canonical: "/search" }, +}; -export default function SearchPage() { - const [q, setQ] = useState(""); - const play = usePlayer((s) => s.play); - const { data: songs = [] } = useSongs(); - - const results = q - ? songs.filter( - (s) => - s.title.toLowerCase().includes(q.toLowerCase()) || - s.artist.toLowerCase().includes(q.toLowerCase()), - ) - : songs; - - return ( - <main className="scroll-pane flex-1 overflow-y-auto p-4 sm:p-6"> - <h1 className="text-2xl font-bold">Search</h1> - <div className="mt-4 flex items-center gap-2 rounded-full bg-white/10 px-5 py-3"> - <SearchIcon className="h-4 w-4 text-white/60" /> - <input - value={q} - onChange={(e) => setQ(e.target.value)} - placeholder="What do you want to listen to?" - className="flex-1 bg-transparent text-sm outline-none placeholder:text-white/40" - /> - </div> - {results.length === 0 ? ( - <EmptyState title="No results" description="Try a different search term." /> - ) : ( - <div className="mt-6 space-y-1"> - {results.map((s, i) => ( - <button - key={s.id} - onClick={() => play(s)} - className="flex w-full items-center gap-3 rounded px-3 py-2 text-left transition-colors duration-150 ease-out hover:bg-white/5" - > - <span className="w-6 text-right text-sm tabular-nums text-white/40">{i + 1}</span> - <div className="h-10 w-10 shrink-0 rounded" style={{ background: s.color }} /> - <div className="min-w-0 flex-1"> - <p className="truncate text-sm font-medium">{s.title}</p> - <p className="truncate text-xs text-white/50">{s.artist}</p> - </div> - <span className="text-xs text-white/50 tabular-nums"> - {Math.floor(s.duration / 60)}:{String(s.duration % 60).padStart(2, "0")} - </span> - <Play className="h-4 w-4 text-white/40" /> - </button> - ))} - </div> - )} - </main> - ); +export default function Page() { + return <SearchPage />; } diff --git a/src/app/(app)/search/search-client.tsx b/src/app/(app)/search/search-client.tsx new file mode 100644 index 0000000..e36dc73 --- /dev/null +++ b/src/app/(app)/search/search-client.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Play, Search as SearchIcon } from "lucide-react"; +import { useState } from "react"; +import { EmptyState } from "@/components/ui/empty-state"; +import { useSongs } from "@/lib/queries"; +import { usePlayer } from "@/stores/player"; + +export default function SearchPage() { + const [q, setQ] = useState(""); + const play = usePlayer((s) => s.play); + const { data: songs = [] } = useSongs(); + + const results = q + ? songs.filter( + (s) => + s.title.toLowerCase().includes(q.toLowerCase()) || + s.artist.toLowerCase().includes(q.toLowerCase()), + ) + : songs; + + return ( + <main className="scroll-pane flex-1 overflow-y-auto p-4 sm:p-6"> + <h1 className="text-2xl font-bold">Search</h1> + <div className="mt-4 flex items-center gap-2 rounded-full bg-white/10 px-5 py-3"> + <SearchIcon className="h-4 w-4 text-white/60" /> + <input + value={q} + onChange={(e) => setQ(e.target.value)} + placeholder="What do you want to listen to?" + className="flex-1 bg-transparent text-sm outline-none placeholder:text-white/40" + /> + </div> + {results.length === 0 ? ( + <EmptyState title="No results" description="Try a different search term." /> + ) : ( + <div className="mt-6 space-y-1"> + {results.map((s, i) => ( + <button + key={s.id} + onClick={() => play(s)} + className="flex w-full items-center gap-3 rounded px-3 py-2 text-left transition-colors duration-150 ease-out hover:bg-white/5" + > + <span className="w-6 text-right text-sm tabular-nums text-white/40">{i + 1}</span> + <div className="h-10 w-10 shrink-0 rounded" style={{ background: s.color }} /> + <div className="min-w-0 flex-1"> + <p className="truncate text-sm font-medium">{s.title}</p> + <p className="truncate text-xs text-white/50">{s.artist}</p> + </div> + <span className="text-xs text-white/50 tabular-nums"> + {Math.floor(s.duration / 60)}:{String(s.duration % 60).padStart(2, "0")} + </span> + <Play className="h-4 w-4 text-white/40" /> + </button> + ))} + </div> + )} + </main> + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e6c2830..a44ad8b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,3 +1,5 @@ +import { Analytics } from "@vercel/analytics/next"; +import { SpeedInsights } from "@vercel/speed-insights/next"; import type { Metadata } from "next"; import { Figtree, Geist_Mono } from "next/font/google"; import { Toaster } from "sonner"; @@ -5,6 +7,7 @@ import { KeyboardShortcuts } from "@/components/keyboard-shortcuts"; import { PlayerProvider } from "@/components/player-provider"; import { Providers } from "@/components/providers"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { DEFAULT_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/site"; import "./globals.css"; const figtree = Figtree({ @@ -18,8 +21,23 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "sunao", - description: "A Spotify-like music player", + metadataBase: new URL(SITE_URL), + title: { template: "%s | sunao", default: "sunao" }, + description: DEFAULT_DESCRIPTION, + applicationName: SITE_NAME, + openGraph: { + type: "website", + siteName: SITE_NAME, + title: SITE_NAME, + description: DEFAULT_DESCRIPTION, + url: "/", + locale: "en_US", + }, + twitter: { + card: "summary_large_image", + title: SITE_NAME, + description: DEFAULT_DESCRIPTION, + }, }; export default function RootLayout({ @@ -51,6 +69,8 @@ export default function RootLayout({ }} /> </Providers> + <Analytics /> + <SpeedInsights /> </body> </html> ); diff --git a/src/app/login/login-client.tsx b/src/app/login/login-client.tsx new file mode 100644 index 0000000..ede65c2 --- /dev/null +++ b/src/app/login/login-client.tsx @@ -0,0 +1,72 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { createClient } from "@/lib/supabase/client"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState<string | null>(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + const supabase = createClient(); + const { error: signInError } = await supabase.auth.signInWithPassword({ + email, + password, + }); + if (signInError) { + setError(signInError.message); + setLoading(false); + return; + } + router.push("/"); + router.refresh(); + }; + + return ( + <div className="flex min-h-dvh items-center justify-center bg-black px-4"> + <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#181818] p-6"> + <h1 className="mb-4 text-2xl font-bold">Sign in</h1> + <form onSubmit={handleSubmit} className="flex flex-col gap-3"> + <input + type="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder="Email" + required + className="rounded bg-white/10 px-3 py-2 text-sm outline-none" + /> + <input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder="Password" + required + className="rounded bg-white/10 px-3 py-2 text-sm outline-none" + /> + {error && <p className="text-sm text-red-500">{error}</p>} + <button + type="submit" + disabled={loading} + className="rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50" + > + Sign in + </button> + </form> + <p className="mt-4 text-sm text-white/60"> + No account?{" "} + <Link href="/signup" className="text-red-400 hover:text-red-300"> + Sign up + </Link> + </p> + </div> + </div> + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ede65c2..16b8752 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,72 +1,13 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { createClient } from "@/lib/supabase/client"; - -export default function LoginPage() { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState<string | null>(null); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(null); - const supabase = createClient(); - const { error: signInError } = await supabase.auth.signInWithPassword({ - email, - password, - }); - if (signInError) { - setError(signInError.message); - setLoading(false); - return; - } - router.push("/"); - router.refresh(); - }; - - return ( - <div className="flex min-h-dvh items-center justify-center bg-black px-4"> - <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#181818] p-6"> - <h1 className="mb-4 text-2xl font-bold">Sign in</h1> - <form onSubmit={handleSubmit} className="flex flex-col gap-3"> - <input - type="email" - value={email} - onChange={(e) => setEmail(e.target.value)} - placeholder="Email" - required - className="rounded bg-white/10 px-3 py-2 text-sm outline-none" - /> - <input - type="password" - value={password} - onChange={(e) => setPassword(e.target.value)} - placeholder="Password" - required - className="rounded bg-white/10 px-3 py-2 text-sm outline-none" - /> - {error && <p className="text-sm text-red-500">{error}</p>} - <button - type="submit" - disabled={loading} - className="rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50" - > - Sign in - </button> - </form> - <p className="mt-4 text-sm text-white/60"> - No account?{" "} - <Link href="/signup" className="text-red-400 hover:text-red-300"> - Sign up - </Link> - </p> - </div> - </div> - ); +import type { Metadata } from "next"; +import LoginPage from "./login-client"; + +export const metadata: Metadata = { + title: "Sign in", + description: "Sign in to sunao to open your library, playlists, and queue.", + alternates: { canonical: "/login" }, + robots: { index: false, follow: true }, +}; + +export default function Page() { + return <LoginPage />; } diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx new file mode 100644 index 0000000..2c4be3e --- /dev/null +++ b/src/app/opengraph-image.tsx @@ -0,0 +1,30 @@ +import { ImageResponse } from "next/og"; + +export const alt = "sunao, a Spotify-like music player"; +export const size = { + width: 1200, + height: 630, +}; +export const contentType = "image/png"; + +export default function Image() { + return new ImageResponse( + <div + style={{ + width: "100%", + height: "100%", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + background: "#121212", + }} + > + <div style={{ display: "flex", color: "#ffffff", fontSize: 160, fontWeight: 700 }}>sunao</div> + <div style={{ display: "flex", color: "#b3b3b3", fontSize: 48, marginTop: 24 }}> + A Spotify-like music player + </div> + </div>, + { ...size }, + ); +} diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..4950133 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,13 @@ +import type { MetadataRoute } from "next"; +import { SITE_URL } from "@/lib/site"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + disallow: ["/auth/"], + }, + sitemap: `${SITE_URL}/sitemap.xml`, + }; +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 8fcff10..3b54a17 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,72 +1,13 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { createClient } from "@/lib/supabase/client"; - -export default function SignupPage() { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState<string | null>(null); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(null); - const supabase = createClient(); - const { error: signUpError } = await supabase.auth.signUp({ - email, - password, - }); - if (signUpError) { - setError(signUpError.message); - setLoading(false); - return; - } - router.push("/"); - router.refresh(); - }; - - return ( - <div className="flex min-h-dvh items-center justify-center bg-black px-4"> - <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#181818] p-6"> - <h1 className="mb-4 text-2xl font-bold">Sign up</h1> - <form onSubmit={handleSubmit} className="flex flex-col gap-3"> - <input - type="email" - value={email} - onChange={(e) => setEmail(e.target.value)} - placeholder="Email" - required - className="rounded bg-white/10 px-3 py-2 text-sm outline-none" - /> - <input - type="password" - value={password} - onChange={(e) => setPassword(e.target.value)} - placeholder="Password" - required - className="rounded bg-white/10 px-3 py-2 text-sm outline-none" - /> - {error && <p className="text-sm text-red-500">{error}</p>} - <button - type="submit" - disabled={loading} - className="rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50" - > - Sign up - </button> - </form> - <p className="mt-4 text-sm text-white/60"> - Have an account?{" "} - <Link href="/login" className="text-red-400 hover:text-red-300"> - Sign in - </Link> - </p> - </div> - </div> - ); +import type { Metadata } from "next"; +import SignupPage from "./signup-client"; + +export const metadata: Metadata = { + title: "Sign up", + description: "Create a sunao account to build playlists and keep your library.", + alternates: { canonical: "/signup" }, + robots: { index: false, follow: true }, +}; + +export default function Page() { + return <SignupPage />; } diff --git a/src/app/signup/signup-client.tsx b/src/app/signup/signup-client.tsx new file mode 100644 index 0000000..8fcff10 --- /dev/null +++ b/src/app/signup/signup-client.tsx @@ -0,0 +1,72 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { createClient } from "@/lib/supabase/client"; + +export default function SignupPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState<string | null>(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + const supabase = createClient(); + const { error: signUpError } = await supabase.auth.signUp({ + email, + password, + }); + if (signUpError) { + setError(signUpError.message); + setLoading(false); + return; + } + router.push("/"); + router.refresh(); + }; + + return ( + <div className="flex min-h-dvh items-center justify-center bg-black px-4"> + <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#181818] p-6"> + <h1 className="mb-4 text-2xl font-bold">Sign up</h1> + <form onSubmit={handleSubmit} className="flex flex-col gap-3"> + <input + type="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder="Email" + required + className="rounded bg-white/10 px-3 py-2 text-sm outline-none" + /> + <input + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder="Password" + required + className="rounded bg-white/10 px-3 py-2 text-sm outline-none" + /> + {error && <p className="text-sm text-red-500">{error}</p>} + <button + type="submit" + disabled={loading} + className="rounded-full bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50" + > + Sign up + </button> + </form> + <p className="mt-4 text-sm text-white/60"> + Have an account?{" "} + <Link href="/login" className="text-red-400 hover:text-red-300"> + Sign in + </Link> + </p> + </div> + </div> + ); +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..e9d3ff0 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,11 @@ +import type { MetadataRoute } from "next"; +import { PLAYLISTS } from "@/lib/data/songs"; +import { SITE_URL } from "@/lib/site"; + +export default function sitemap(): MetadataRoute.Sitemap { + return [ + { url: `${SITE_URL}/`, priority: 1 }, + { url: `${SITE_URL}/search`, priority: 0.6 }, + ...PLAYLISTS.map((p) => ({ url: `${SITE_URL}/playlist/${p.id}`, priority: 0.7 })), + ]; +} diff --git a/src/app/twitter-image.tsx b/src/app/twitter-image.tsx new file mode 100644 index 0000000..8f726ce --- /dev/null +++ b/src/app/twitter-image.tsx @@ -0,0 +1 @@ +export { alt, contentType, default, size } from "./opengraph-image"; diff --git a/src/lib/site.ts b/src/lib/site.ts new file mode 100644 index 0000000..e377d87 --- /dev/null +++ b/src/lib/site.ts @@ -0,0 +1,3 @@ +export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://sunao-omega.vercel.app"; +export const SITE_NAME = "sunao"; +export const DEFAULT_DESCRIPTION = "A Spotify-like music player";