Merge branch 'DEV' into feature/node-app
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
VITE_API_BASE_URL=http://localhost:8085
|
||||
VITE_API_KEY=
|
||||
# Leave empty — vite proxy handles /api in dev, same-origin in production
|
||||
VITE_FURUMI_API_URL=
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const DB_NAME = 'furumi-sw'
|
||||
const STORE = 'auth'
|
||||
const KEY = 'bearer'
|
||||
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1)
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE)
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function getToken() {
|
||||
try {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction(STORE, 'readonly')
|
||||
const req = tx.objectStore(STORE).get(KEY)
|
||||
req.onsuccess = () => resolve(req.result || null)
|
||||
req.onerror = () => resolve(null)
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const url = new URL(e.request.url)
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith('/api/')) return
|
||||
|
||||
e.respondWith(
|
||||
(async () => {
|
||||
const token = await getToken()
|
||||
if (!token) return fetch(e.request)
|
||||
|
||||
const headers = new Headers(e.request.headers)
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
return fetch(new Request(e.request, { headers }))
|
||||
})()
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()))
|
||||
@@ -1,71 +1,102 @@
|
||||
.page {
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #0a0c12;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(520px, 100%);
|
||||
border: 1px solid #d8dde6;
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
/* ---------- loading spinner ---------- */
|
||||
|
||||
.subtitle {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: #5a6475;
|
||||
}
|
||||
|
||||
.settings {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e6eaf2;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
.auth-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid #1f2c45;
|
||||
border-top-color: #7c6af7;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 10px 0 0;
|
||||
color: #5a6475;
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
background: #2251ff;
|
||||
color: #ffffff;
|
||||
padding: 10px 16px;
|
||||
.auth-loading .logo {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #7c6af7;
|
||||
}
|
||||
|
||||
.auth-loading p {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---------- login card ---------- */
|
||||
|
||||
.auth-card {
|
||||
width: min(380px, 100%);
|
||||
background: #111520;
|
||||
border: 1px solid #1f2c45;
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.auth-card .logo {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: #7c6af7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.auth-card .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-card .btn-login {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
background: #7c6af7;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: #edf1ff;
|
||||
color: #1e3fc4;
|
||||
margin-top: 10px;
|
||||
.auth-card .btn-login:hover {
|
||||
background: #6b58e8;
|
||||
}
|
||||
|
||||
.profile p {
|
||||
margin: 8px 0;
|
||||
.auth-card .error {
|
||||
color: #f87171;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #cc1e1e;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FurumiPlayer } from './FurumiPlayer'
|
||||
import { setAuthToken, clearAuthToken } from './furumiApi'
|
||||
import './App.css'
|
||||
|
||||
type UserProfile = {
|
||||
@@ -8,30 +9,12 @@ type UserProfile = {
|
||||
email?: string
|
||||
}
|
||||
|
||||
const NO_AUTH_STORAGE_KEY = 'furumiNodePlayer.runWithoutAuth'
|
||||
|
||||
function App() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [user, setUser] = useState<UserProfile | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [runWithoutAuth, setRunWithoutAuth] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(NO_AUTH_STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const apiBase = useMemo(() => import.meta.env.VITE_API_BASE_URL ?? '', [])
|
||||
|
||||
useEffect(() => {
|
||||
if (runWithoutAuth) {
|
||||
setError(null)
|
||||
setUser({ sub: 'noauth', name: 'No Auth' })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const loadMe = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/me`, {
|
||||
@@ -40,6 +23,7 @@ function App() {
|
||||
|
||||
if (response.status === 401) {
|
||||
setUser(null)
|
||||
clearAuthToken()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,6 +33,20 @@ function App() {
|
||||
|
||||
const data = await response.json()
|
||||
setUser(data.user ?? null)
|
||||
|
||||
if (data.user) {
|
||||
try {
|
||||
const tokenRes = await fetch('/auth/token', { credentials: 'include' })
|
||||
if (tokenRes.ok) {
|
||||
const tokenData = await tokenRes.json()
|
||||
if (tokenData.access_token) {
|
||||
setAuthToken(tokenData.access_token)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Token fetch failed
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load session')
|
||||
} finally {
|
||||
@@ -57,84 +55,42 @@ function App() {
|
||||
}
|
||||
|
||||
void loadMe()
|
||||
}, [apiBase, runWithoutAuth])
|
||||
}, [])
|
||||
|
||||
const loginUrl = `/api/login`
|
||||
const logoutUrl = `/api/logout`
|
||||
// Authenticated — render player immediately
|
||||
if (!loading && user) {
|
||||
return <FurumiPlayer user={user} />
|
||||
}
|
||||
|
||||
// Loading — show spinner (no login form flash)
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<div className="auth-loading">
|
||||
<div className="logo">Furumi</div>
|
||||
<div className="spinner" />
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// Not authenticated — show login
|
||||
return (
|
||||
<>
|
||||
{!loading && (user || runWithoutAuth) ? (
|
||||
<FurumiPlayer />
|
||||
) : (
|
||||
<main className="page">
|
||||
<section className="card">
|
||||
<h1>OIDC Login</h1>
|
||||
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
|
||||
<main className="auth-page">
|
||||
<section className="auth-card">
|
||||
<div className="logo">Furumi</div>
|
||||
<p className="subtitle">Sign in to continue</p>
|
||||
|
||||
<div className="settings">
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={runWithoutAuth}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked
|
||||
setRunWithoutAuth(next)
|
||||
try {
|
||||
if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
|
||||
else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setLoading(true)
|
||||
setUser(null)
|
||||
}}
|
||||
/>
|
||||
<span>Запускать без авторизации</span>
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{loading && <p>Проверяю сессию...</p>}
|
||||
{error && <p className="error">Ошибка: {error}</p>}
|
||||
|
||||
{!loading && runWithoutAuth && (
|
||||
<p className="hint">
|
||||
Режим без авторизации включён. Для входа отключи настройку выше.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !user && (
|
||||
<a className="btn" href={loginUrl}>
|
||||
Войти через OIDC
|
||||
</a>
|
||||
)}
|
||||
|
||||
{!loading && user && (
|
||||
<div className="profile">
|
||||
<p>
|
||||
<strong>ID:</strong> {user.sub}
|
||||
</p>
|
||||
{user.name && (
|
||||
<p>
|
||||
<strong>Имя:</strong> {user.name}
|
||||
</p>
|
||||
)}
|
||||
{user.email && (
|
||||
<p>
|
||||
<strong>Email:</strong> {user.email}
|
||||
</p>
|
||||
)}
|
||||
{!runWithoutAuth && (
|
||||
<a className="btn ghost" href={logoutUrl}>
|
||||
Выйти
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)}
|
||||
</>
|
||||
<a className="btn-login" href="/auth/login">
|
||||
Sign in with SSO
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import './furumi-player.css'
|
||||
import { API_ROOT, searchTracks, preloadStream } from './furumiApi'
|
||||
import { furumiApi, searchTracks, recordPlay } from './furumiApi'
|
||||
import { store, useAppDispatch, useAppSelector } from './store'
|
||||
import { fetchArtists } from './store/slices/artistsSlice'
|
||||
import { fetchArtistAlbums } from './store/slices/albumsSlice'
|
||||
@@ -29,7 +29,13 @@ import { MainPanel, type Crumb } from './components/MainPanel'
|
||||
import { PlayerBar } from './components/PlayerBar'
|
||||
import type { Track } from './types'
|
||||
|
||||
export function FurumiPlayer() {
|
||||
export type UserProfile = {
|
||||
sub: string
|
||||
name?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export function FurumiPlayer({ user }: { user: UserProfile }) {
|
||||
const dispatch = useAppDispatch()
|
||||
const artistsLoading = useAppSelector((s) => s.artists.loading)
|
||||
const artistsError = useAppSelector((s) => s.artists.error)
|
||||
@@ -80,16 +86,19 @@ export function FurumiPlayer() {
|
||||
return
|
||||
}
|
||||
document.title = `${nowPlayingTrack.title} — Furumi`
|
||||
const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover`
|
||||
if ('mediaSession' in navigator) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||
const meta = new window.MediaMetadata({
|
||||
title: nowPlayingTrack.title,
|
||||
artist: nowPlayingTrack.artist || '',
|
||||
album: '',
|
||||
artwork: [{ src: coverUrl, sizes: '512x512' }],
|
||||
})
|
||||
navigator.mediaSession.metadata = meta
|
||||
furumiApi.get(`/tracks/${nowPlayingTrack.slug}/cover`, { responseType: 'blob' })
|
||||
.then((res) => {
|
||||
meta.artwork = [{ src: URL.createObjectURL(res.data), sizes: '512x512' }]
|
||||
})
|
||||
.catch(() => {})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -292,6 +301,7 @@ export function FurumiPlayer() {
|
||||
dispatch(playAtIndex(i))
|
||||
const track = store.getState().queue.items[i]
|
||||
void playback.loadStreamForTrack(track.slug)
|
||||
void recordPlay(track.slug)
|
||||
if (window.history && window.history.replaceState) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('t', track.slug)
|
||||
@@ -384,7 +394,7 @@ export function FurumiPlayer() {
|
||||
{ slug, title: '', artist: '', album_slug: null, duration: null },
|
||||
true,
|
||||
)
|
||||
void preloadStream(slug)
|
||||
void playback.loadStreamForTrack(slug)
|
||||
}
|
||||
}
|
||||
searchSelectRef.current = onSearchSelect
|
||||
@@ -512,6 +522,8 @@ export function FurumiPlayer() {
|
||||
searchOpen={searchOpen}
|
||||
searchResults={searchResults}
|
||||
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
||||
onPlayTrack={(slug) => searchSelectRef.current('track', slug)}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
<MainPanel
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { preloadStream } from './furumiApi'
|
||||
import { furumiApi } from './furumiApi'
|
||||
import { fmt } from './utils'
|
||||
|
||||
const MAX_PLAYBACK_ERROR_SKIPS = 5
|
||||
@@ -109,9 +109,13 @@ export function attachAudioPlayback(
|
||||
volSlider?.addEventListener('input', onVolInput)
|
||||
|
||||
async function loadStreamForTrack(slug: string) {
|
||||
const response = await preloadStream(slug)
|
||||
audio.src = URL.createObjectURL(response?.data)
|
||||
await audio.play().catch(() => { })
|
||||
try {
|
||||
const res = await furumiApi.get(`/stream/${slug}`, { responseType: 'blob' })
|
||||
audio.src = URL.createObjectURL(res.data)
|
||||
await audio.play().catch(() => { })
|
||||
} catch {
|
||||
// stream failed
|
||||
}
|
||||
}
|
||||
|
||||
function pauseAndClearSource() {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { furumiApi } from '../furumiApi'
|
||||
|
||||
export function AuthImg({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!src) return
|
||||
let revoked = false
|
||||
furumiApi.get(src, { responseType: 'blob' })
|
||||
.then((res) => {
|
||||
if (!revoked) setBlobUrl(URL.createObjectURL(res.data))
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
revoked = true
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [src])
|
||||
|
||||
if (!blobUrl) return null
|
||||
return <img src={blobUrl} alt={alt ?? ''} {...props} />
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { API_ROOT } from '../furumiApi'
|
||||
import { useState } from 'react'
|
||||
import { AuthImg } from './AuthImg'
|
||||
import type { QueueItem } from './QueueList'
|
||||
|
||||
function Cover({ src }: { src: string }) {
|
||||
function Cover({ slug }: { slug: string }) {
|
||||
const [errored, setErrored] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setErrored(false)
|
||||
}, [src])
|
||||
const src = `/tracks/${slug}/cover`
|
||||
|
||||
if (errored) return <>🎵</>
|
||||
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||
return <AuthImg src={src} alt="" onError={() => setErrored(true)} />
|
||||
}
|
||||
|
||||
export function NowPlaying({ track }: { track: QueueItem | null }) {
|
||||
@@ -32,12 +29,10 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
|
||||
|
||||
return (
|
||||
<div className="np-info">
|
||||
<div className="np-cover" id="npCover">
|
||||
<Cover src={coverUrl} />
|
||||
<Cover slug={track.slug} />
|
||||
</div>
|
||||
<div className="np-text">
|
||||
<div className="np-title" id="npTitle">
|
||||
@@ -50,4 +45,3 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { API_ROOT } from '../furumiApi'
|
||||
import { AuthImg } from './AuthImg'
|
||||
|
||||
export type QueueItem = {
|
||||
slug: string
|
||||
@@ -32,14 +32,10 @@ function fmt(secs: number) {
|
||||
return `${m}:${pad(s % 60)}`
|
||||
}
|
||||
|
||||
function Cover({ src }: { src: string }) {
|
||||
function Cover({ slug }: { slug: string }) {
|
||||
const [errored, setErrored] = useState(false)
|
||||
useEffect(() => {
|
||||
setErrored(false)
|
||||
}, [src])
|
||||
|
||||
if (errored) return <>🎵</>
|
||||
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||
return <AuthImg src={`/tracks/${slug}/cover`} alt="" onError={() => setErrored(true)} />
|
||||
}
|
||||
|
||||
export function QueueList({
|
||||
@@ -77,7 +73,7 @@ export function QueueList({
|
||||
if (!t) return null
|
||||
|
||||
const isPlaying = origIdx === playingOrigIdx
|
||||
const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : ''
|
||||
const hasAlbum = !!t.album_slug
|
||||
const dur = t.duration ? fmt(t.duration) : ''
|
||||
const isDragging = draggingPos === pos
|
||||
const isDragOver = dragOverPos === pos
|
||||
@@ -118,7 +114,7 @@ export function QueueList({
|
||||
>
|
||||
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
|
||||
<div className="qi-cover">
|
||||
{coverSrc ? <Cover src={coverSrc} /> : <>🎵</>}
|
||||
{hasAlbum ? <Cover slug={t.slug} /> : <>🎵</>}
|
||||
</div>
|
||||
<div className="qi-info">
|
||||
<div className="qi-title">{t.title}</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { SearchDropdown } from '../SearchDropdown'
|
||||
import { RecentPlays } from './RecentPlays'
|
||||
import styles from './header.module.css'
|
||||
|
||||
type SearchResultItem = {
|
||||
@@ -8,39 +10,100 @@ type SearchResultItem = {
|
||||
detail?: string
|
||||
}
|
||||
|
||||
type UserInfo = {
|
||||
sub: string
|
||||
name?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
type HeaderProps = {
|
||||
searchOpen: boolean
|
||||
searchResults: SearchResultItem[]
|
||||
onSearchSelect: (type: string, slug: string) => void
|
||||
onPlayTrack: (slug: string) => void
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
function UserMenu({ user, onShowRecent }: { user: UserInfo; onShowRecent: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
const initials = (user.name ?? user.sub)
|
||||
.split(' ')
|
||||
.map((w) => w[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<div className={styles.userMenu} ref={ref}>
|
||||
<button className={styles.userAvatar} onClick={() => setOpen(!open)} title={user.name ?? user.sub}>
|
||||
{initials}
|
||||
</button>
|
||||
{open && (
|
||||
<div className={styles.userDropdown}>
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.userName}>{user.name ?? user.sub}</div>
|
||||
{user.email && <div className={styles.userEmail}>{user.email}</div>}
|
||||
</div>
|
||||
<button className={styles.userAction} onClick={() => { setOpen(false); onShowRecent() }}>
|
||||
Recent plays
|
||||
</button>
|
||||
<a href="/auth/logout" className={styles.userLogout}>Sign out</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header({
|
||||
searchOpen,
|
||||
searchResults,
|
||||
onSearchSelect,
|
||||
onPlayTrack,
|
||||
user,
|
||||
}: HeaderProps) {
|
||||
const [showRecent, setShowRecent] = useState(false)
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerLogo}>
|
||||
<button className="btn-menu">☰</button>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="9" cy="18" r="3" />
|
||||
<circle cx="18" cy="15" r="3" />
|
||||
<path d="M12 18V6l9-3v3" />
|
||||
</svg>
|
||||
Furumi
|
||||
<span className={styles.headerVersion}>v</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div className="search-wrap">
|
||||
<input id="searchInput" placeholder="Search..." />
|
||||
<SearchDropdown
|
||||
isOpen={searchOpen}
|
||||
results={searchResults}
|
||||
onSelect={onSearchSelect}
|
||||
/>
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerLogo}>
|
||||
<button className="btn-menu">☰</button>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="9" cy="18" r="3" />
|
||||
<circle cx="18" cy="15" r="3" />
|
||||
<path d="M12 18V6l9-3v3" />
|
||||
</svg>
|
||||
Furumi
|
||||
<span className={styles.headerVersion}>v</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div className="search-wrap">
|
||||
<input id="searchInput" placeholder="Search..." />
|
||||
<SearchDropdown
|
||||
isOpen={searchOpen}
|
||||
results={searchResults}
|
||||
onSelect={onSearchSelect}
|
||||
/>
|
||||
</div>
|
||||
<UserMenu user={user} onShowRecent={() => setShowRecent(true)} />
|
||||
</div>
|
||||
</header>
|
||||
{showRecent && (
|
||||
<RecentPlays
|
||||
onClose={() => setShowRecent(false)}
|
||||
onPlay={onPlayTrack}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getRecentPlays, type RecentPlay } from '../../furumiApi'
|
||||
import styles from './header.module.css'
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export function RecentPlays({ onClose, onPlay }: { onClose: () => void; onPlay: (slug: string) => void }) {
|
||||
const [plays, setPlays] = useState<RecentPlay[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
getRecentPlays().then((data) => {
|
||||
setPlays(data)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className={styles.recentOverlay} onClick={onClose}>
|
||||
<div className={styles.recentPanel} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.recentHeader}>
|
||||
<h2>Recent plays</h2>
|
||||
<button className={styles.recentClose} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className={styles.recentList}>
|
||||
{loading && <p className={styles.recentEmpty}>Loading...</p>}
|
||||
{!loading && (!plays || plays.length === 0) && (
|
||||
<p className={styles.recentEmpty}>No play history yet</p>
|
||||
)}
|
||||
{plays?.map((p, i) => (
|
||||
<div
|
||||
key={`${p.track_slug}-${i}`}
|
||||
className={styles.recentItem}
|
||||
onClick={() => { onPlay(p.track_slug); onClose() }}
|
||||
>
|
||||
<div className={styles.recentTrack}>
|
||||
<div className={styles.recentTitle}>{p.track_title}</div>
|
||||
<div className={styles.recentArtist}>{p.artist_name}</div>
|
||||
</div>
|
||||
<div className={styles.recentTime}>{timeAgo(p.played_at)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -32,4 +32,203 @@
|
||||
margin-left: 0.25rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* User menu */
|
||||
|
||||
.userMenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.userAvatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.userAvatar:hover {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.userDropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.userEmail {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.userLogout {
|
||||
display: block;
|
||||
padding: 10px 16px;
|
||||
color: var(--danger);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.userLogout:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.userAction {
|
||||
display: block;
|
||||
padding: 10px 16px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.userAction:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Recent plays overlay */
|
||||
|
||||
.recentOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.recentPanel {
|
||||
width: min(480px, 90vw);
|
||||
max-height: 70vh;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.recentHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.recentHeader h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recentClose {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.recentClose:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.recentList {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recentItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.recentItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.recentTrack {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recentTitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recentArtist {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.recentTime {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.recentEmpty {
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -1,16 +1,64 @@
|
||||
import axios from 'axios'
|
||||
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''
|
||||
export const API_ROOT = `${API_BASE}/api`
|
||||
|
||||
const API_KEY = import.meta.env.VITE_API_KEY
|
||||
const FURUMI_API_BASE = import.meta.env.VITE_FURUMI_API_URL ?? ''
|
||||
export const API_ROOT = `${FURUMI_API_BASE}/api`
|
||||
|
||||
export const furumiApi = axios.create({
|
||||
baseURL: API_ROOT,
|
||||
headers: API_KEY ? { 'x-api-key': API_KEY } : {},
|
||||
})
|
||||
|
||||
function sendTokenToSW(token: string) {
|
||||
try {
|
||||
const req = indexedDB.open('furumi-sw', 1)
|
||||
req.onupgradeneeded = () => req.result.createObjectStore('auth')
|
||||
req.onsuccess = () => {
|
||||
const tx = req.result.transaction('auth', 'readwrite')
|
||||
tx.objectStore('auth').put(token, 'bearer')
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string) {
|
||||
furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
sendTokenToSW(token)
|
||||
}
|
||||
|
||||
export function clearAuthToken() {
|
||||
delete furumiApi.defaults.headers.common['Authorization']
|
||||
}
|
||||
|
||||
async function refreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/auth/token', { credentials: 'include' })
|
||||
if (!res.ok) return false
|
||||
const data = await res.json()
|
||||
if (data.access_token) {
|
||||
setAuthToken(data.access_token)
|
||||
return true
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return false
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null
|
||||
|
||||
furumiApi.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const original = error.config
|
||||
if (error.response?.status === 401 && !original._retried) {
|
||||
original._retried = true
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshToken().finally(() => { refreshPromise = null })
|
||||
}
|
||||
const ok = await refreshPromise
|
||||
if (ok) return furumiApi(original)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export async function getArtists(): Promise<Artist[] | null> {
|
||||
const res = await furumiApi.get<Artist[]>('/artists').catch(() => null)
|
||||
return res?.data ?? null
|
||||
@@ -43,7 +91,19 @@ export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | nul
|
||||
return res?.data ?? null
|
||||
}
|
||||
|
||||
export async function preloadStream(trackSlug: string) {
|
||||
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
|
||||
export type RecentPlay = {
|
||||
track_slug: string
|
||||
track_title: string
|
||||
artist_name: string
|
||||
album_slug: string | null
|
||||
played_at: string
|
||||
}
|
||||
|
||||
export async function getRecentPlays(): Promise<RecentPlay[] | null> {
|
||||
const res = await furumiApi.get<RecentPlay[]>('/me/recent').catch(() => null)
|
||||
return res?.data ?? null
|
||||
}
|
||||
|
||||
export async function recordPlay(trackSlug: string): Promise<void> {
|
||||
await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import { store } from './store'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Provider store={store}>
|
||||
|
||||
@@ -6,10 +6,18 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
'/auth': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/callback': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8085',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user