Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7196f8e930 | ||
|
|
a771405db8 | ||
|
|
1e9a96aa0f |
@@ -200,6 +200,10 @@ func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -227,6 +227,10 @@ func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -93,6 +93,12 @@ func (p *Producer) CancelTask(ctx context.Context, id string) error {
|
||||
return p.pb.CancelTask(ctx, id)
|
||||
}
|
||||
|
||||
// CancelAudioTasksBySlug delegates to PocketBase to cancel all pending/running
|
||||
// audio tasks for slug.
|
||||
func (p *Producer) CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error) {
|
||||
return p.pb.CancelAudioTasksBySlug(ctx, slug)
|
||||
}
|
||||
|
||||
// enqueue serialises payload and dispatches it to Asynq.
|
||||
func (p *Producer) enqueue(_ context.Context, taskType string, payload any) error {
|
||||
b, err := json.Marshal(payload)
|
||||
|
||||
@@ -708,12 +708,17 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
||||
// Fast path: if audio already exists in MinIO, redirects to the presigned URL
|
||||
// (same as handleAudioProxy) — the client plays from storage immediately.
|
||||
//
|
||||
// Slow path (first request): streams MP3 audio directly to the client while
|
||||
// simultaneously uploading it to MinIO. After the stream completes, any
|
||||
// pending audio_jobs task for this key is marked done. Subsequent requests hit
|
||||
// the fast path and skip TTS generation entirely.
|
||||
// Slow path (first request): streams audio directly to the client while
|
||||
// simultaneously uploading it to MinIO. After the stream completes, subsequent
|
||||
// requests hit the fast path and skip TTS generation entirely.
|
||||
//
|
||||
// Query params: voice (optional, defaults to DefaultVoice)
|
||||
// Query params:
|
||||
//
|
||||
// voice (optional, defaults to DefaultVoice)
|
||||
// format (optional, "mp3" or "wav"; defaults to "mp3")
|
||||
//
|
||||
// Using format=wav skips the ffmpeg transcode for pocket-tts voices, delivering
|
||||
// raw WAV frames to the client with lower latency at the cost of larger files.
|
||||
func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
@@ -727,7 +732,17 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
voice = s.cfg.DefaultVoice
|
||||
}
|
||||
|
||||
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
|
||||
format := r.URL.Query().Get("format")
|
||||
if format != "wav" {
|
||||
format = "mp3"
|
||||
}
|
||||
|
||||
contentType := "audio/mpeg"
|
||||
if format == "wav" {
|
||||
contentType = "audio/wav"
|
||||
}
|
||||
|
||||
audioKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice, format)
|
||||
|
||||
// ── Fast path: already in MinIO ───────────────────────────────────────────
|
||||
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||
@@ -756,23 +771,39 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Open the TTS stream.
|
||||
// Open the TTS stream (WAV or MP3 depending on format param).
|
||||
var audioStream io.ReadCloser
|
||||
if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
if format == "wav" {
|
||||
if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioWAV(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioWAV(r.Context(), text, voice)
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
|
||||
}
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "err", err)
|
||||
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "format", format, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "tts stream failed")
|
||||
return
|
||||
}
|
||||
@@ -787,11 +818,11 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
go func() {
|
||||
uploadDone <- s.deps.AudioStore.PutAudioStream(
|
||||
context.Background(), // use background — request ctx may cancel after client disconnects
|
||||
audioKey, pr, -1, "audio/mpeg",
|
||||
audioKey, pr, -1, contentType,
|
||||
)
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1081,6 +1112,166 @@ func (s *Server) handleAdminTranslationBulk(w http.ResponseWriter, r *http.Reque
|
||||
})
|
||||
}
|
||||
|
||||
// ── Admin Audio ────────────────────────────────────────────────────────────────
|
||||
|
||||
// handleAdminAudioJobs handles GET /api/admin/audio/jobs.
|
||||
// Returns all audio jobs, optionally filtered by slug (?slug=...).
|
||||
// Sorted by started descending.
|
||||
func (s *Server) handleAdminAudioJobs(w http.ResponseWriter, r *http.Request) {
|
||||
tasks, err := s.deps.TaskReader.ListAudioTasks(r.Context())
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminAudioJobs: ListAudioTasks failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to list audio jobs")
|
||||
return
|
||||
}
|
||||
|
||||
// Optional slug filter.
|
||||
slugFilter := r.URL.Query().Get("slug")
|
||||
|
||||
type jobRow struct {
|
||||
ID string `json:"id"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
Slug string `json:"slug"`
|
||||
Chapter int `json:"chapter"`
|
||||
Voice string `json:"voice"`
|
||||
Status string `json:"status"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished"`
|
||||
}
|
||||
rows := make([]jobRow, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
if slugFilter != "" && t.Slug != slugFilter {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, jobRow{
|
||||
ID: t.ID,
|
||||
CacheKey: t.CacheKey,
|
||||
Slug: t.Slug,
|
||||
Chapter: t.Chapter,
|
||||
Voice: t.Voice,
|
||||
Status: string(t.Status),
|
||||
WorkerID: t.WorkerID,
|
||||
ErrorMessage: t.ErrorMessage,
|
||||
Started: t.Started.Format(time.RFC3339),
|
||||
Finished: t.Finished.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"jobs": rows, "total": len(rows)})
|
||||
}
|
||||
|
||||
// handleAdminAudioBulk handles POST /api/admin/audio/bulk.
|
||||
// Body: {"slug": "...", "voice": "af_bella", "from": 1, "to": 100, "skip_existing": true}
|
||||
//
|
||||
// Enqueues one audio task per chapter in [from, to].
|
||||
// skip_existing (default true): skip chapters already cached in MinIO — use this
|
||||
// to resume a previously interrupted bulk job.
|
||||
// force: if true, enqueue even when a pending/running task already exists.
|
||||
// Max 1000 chapters per request.
|
||||
func (s *Server) handleAdminAudioBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
Voice string `json:"voice"`
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
SkipExisting *bool `json:"skip_existing"` // pointer so we can detect omission
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if body.Voice == "" {
|
||||
body.Voice = s.cfg.DefaultVoice
|
||||
}
|
||||
if body.From < 1 || body.To < body.From {
|
||||
jsonError(w, http.StatusBadRequest, "from must be >= 1 and to must be >= from")
|
||||
return
|
||||
}
|
||||
if body.To-body.From > 999 {
|
||||
jsonError(w, http.StatusBadRequest, "range too large; max 1000 chapters per request")
|
||||
return
|
||||
}
|
||||
|
||||
// skip_existing defaults to true (resume-friendly).
|
||||
skipExisting := true
|
||||
if body.SkipExisting != nil {
|
||||
skipExisting = *body.SkipExisting
|
||||
}
|
||||
|
||||
var taskIDs []string
|
||||
skipped := 0
|
||||
|
||||
for n := body.From; n <= body.To; n++ {
|
||||
// Skip chapters already cached in MinIO.
|
||||
if skipExisting {
|
||||
audioKey := s.deps.AudioStore.AudioObjectKey(body.Slug, n, body.Voice)
|
||||
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Skip chapters with an active (pending/running) task unless force=true.
|
||||
if !body.Force {
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s", body.Slug, n, body.Voice)
|
||||
existing, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
|
||||
if found && (existing.Status == domain.TaskStatusPending || existing.Status == domain.TaskStatusRunning) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
id, err := s.deps.Producer.CreateAudioTask(r.Context(), body.Slug, n, body.Voice)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminAudioBulk: CreateAudioTask failed",
|
||||
"slug", body.Slug, "chapter", n, "voice", body.Voice, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError,
|
||||
fmt.Sprintf("failed to create task for chapter %d: %s", n, err))
|
||||
return
|
||||
}
|
||||
taskIDs = append(taskIDs, id)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"enqueued": len(taskIDs),
|
||||
"skipped": skipped,
|
||||
"task_ids": taskIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminAudioCancelBulk handles POST /api/admin/audio/cancel-bulk.
|
||||
// Body: {"slug": "..."}
|
||||
// Cancels all pending and running audio tasks for the given slug.
|
||||
func (s *Server) handleAdminAudioCancelBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
cancelled, err := s.deps.Producer.CancelAudioTasksBySlug(r.Context(), body.Slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminAudioCancelBulk: CancelAudioTasksBySlug failed",
|
||||
"slug", body.Slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to cancel tasks")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"cancelled": cancelled})
|
||||
}
|
||||
|
||||
// ── Voices ─────────────────────────────────────────────────────────────────────
|
||||
// Returns {"voices": [...]} — merged list from Kokoro and pocket-tts.
|
||||
func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -174,6 +174,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
|
||||
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
|
||||
|
||||
// Admin audio endpoints
|
||||
mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
|
||||
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
|
||||
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
|
||||
|
||||
// Voices list
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
|
||||
|
||||
@@ -80,9 +80,14 @@ type RankingStore interface {
|
||||
|
||||
// AudioStore covers audio object storage (runner writes; backend reads).
|
||||
type AudioStore interface {
|
||||
// AudioObjectKey returns the MinIO object key for a cached audio file.
|
||||
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
|
||||
// Format: {slug}/{n}/{voice}.mp3
|
||||
AudioObjectKey(slug string, n int, voice string) string
|
||||
|
||||
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
|
||||
// with a custom extension (e.g. "mp3" or "wav").
|
||||
AudioObjectKeyExt(slug string, n int, voice, ext string) string
|
||||
|
||||
// AudioExists returns true when the audio object is present in MinIO.
|
||||
AudioExists(ctx context.Context, key string) bool
|
||||
|
||||
@@ -91,7 +96,7 @@ type AudioStore interface {
|
||||
|
||||
// PutAudioStream uploads audio from r to MinIO under key.
|
||||
// size must be the exact byte length of r, or -1 to use multipart upload.
|
||||
// contentType should be "audio/mpeg".
|
||||
// contentType should be "audio/mpeg" or "audio/wav".
|
||||
PutAudioStream(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
||||
}
|
||||
|
||||
|
||||
@@ -52,9 +52,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
|
||||
}
|
||||
|
||||
// AudioStore
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ type Client interface {
|
||||
// waiting for the full output. The caller must always close the ReadCloser.
|
||||
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// StreamAudioWAV synthesises text and returns an io.ReadCloser that streams
|
||||
// WAV-encoded audio incrementally using kokoro-fastapi's streaming mode with
|
||||
// response_format:"wav". The caller must always close the ReadCloser.
|
||||
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// ListVoices returns the available voice IDs. Falls back to an empty slice
|
||||
// on error — callers should treat an empty list as "service unavailable".
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
@@ -167,6 +172,47 @@ func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// StreamAudioWAV calls POST /v1/audio/speech with stream:true and response_format:wav,
|
||||
// returning an io.ReadCloser that delivers WAV bytes as kokoro generates them.
|
||||
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("kokoro: empty text")
|
||||
}
|
||||
if voice == "" {
|
||||
voice = "af_bella"
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(map[string]any{
|
||||
"model": "kokoro",
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": "wav",
|
||||
"speed": 1.0,
|
||||
"stream": true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: marshal wav stream request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/v1/audio/speech", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: build wav stream request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: wav stream request: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("kokoro: wav stream returned %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// ListVoices calls GET /v1/audio/voices and returns the list of voice IDs.
|
||||
func (c *httpClient) ListVoices(ctx context.Context) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
|
||||
@@ -59,6 +59,12 @@ type Client interface {
|
||||
// The caller must always close the returned ReadCloser.
|
||||
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// StreamAudioWAV synthesises text and returns an io.ReadCloser that streams
|
||||
// raw WAV audio directly from pocket-tts without any transcoding.
|
||||
// The stream begins with a WAV header followed by 16-bit PCM frames at 16 kHz.
|
||||
// The caller must always close the returned ReadCloser.
|
||||
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// ListVoices returns the available predefined voice names.
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
}
|
||||
@@ -160,6 +166,25 @@ func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// StreamAudioWAV posts to POST /tts and returns an io.ReadCloser that delivers
|
||||
// raw WAV bytes directly from pocket-tts — no ffmpeg transcoding required.
|
||||
// The first bytes will be a WAV header (RIFF/fmt chunk) followed by PCM frames.
|
||||
// The caller must always close the returned ReadCloser.
|
||||
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("pockettts: empty text")
|
||||
}
|
||||
if voice == "" {
|
||||
voice = "alba"
|
||||
}
|
||||
|
||||
resp, err := c.postTTS(ctx, text, voice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// ListVoices returns the statically known predefined voice names.
|
||||
// pocket-tts has no REST endpoint for listing voices.
|
||||
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
|
||||
|
||||
@@ -126,6 +126,9 @@ type stubAudioStore struct {
|
||||
func (s *stubAudioStore) AudioObjectKey(slug string, n int, voice string) string {
|
||||
return slug + "/" + string(rune('0'+n)) + "/" + voice + ".mp3"
|
||||
}
|
||||
func (s *stubAudioStore) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||
return slug + "/" + string(rune('0'+n)) + "/" + voice + "." + ext
|
||||
}
|
||||
func (s *stubAudioStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error {
|
||||
s.putCalled.Add(1)
|
||||
@@ -199,6 +202,14 @@ func (s *stubKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
|
||||
return io.NopCloser(bytes.NewReader(s.data)), nil
|
||||
}
|
||||
|
||||
func (s *stubKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
||||
s.called.Add(1)
|
||||
if s.genErr != nil {
|
||||
return nil, s.genErr
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(s.data)), nil
|
||||
}
|
||||
|
||||
func (s *stubKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return []string{"af_bella"}, nil
|
||||
}
|
||||
|
||||
@@ -109,10 +109,17 @@ func ChapterObjectKey(slug string, n int) string {
|
||||
return fmt.Sprintf("%s/chapter-%06d.md", slug, n)
|
||||
}
|
||||
|
||||
// AudioObjectKey returns the MinIO object key for a cached audio file.
|
||||
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
|
||||
// with a custom extension (e.g. "mp3" or "wav").
|
||||
// Format: {slug}/{n}/{voice}.{ext}
|
||||
func AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||
return fmt.Sprintf("%s/%d/%s.%s", slug, n, voice, ext)
|
||||
}
|
||||
|
||||
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
|
||||
// Format: {slug}/{n}/{voice}.mp3
|
||||
func AudioObjectKey(slug string, n int, voice string) string {
|
||||
return fmt.Sprintf("%s/%d/%s.mp3", slug, n, voice)
|
||||
return AudioObjectKeyExt(slug, n, voice, "mp3")
|
||||
}
|
||||
|
||||
// AvatarObjectKey returns the MinIO object key for a user avatar image.
|
||||
|
||||
@@ -74,12 +74,24 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
|
||||
"rating": meta.Rating,
|
||||
}
|
||||
// Upsert via filter: if exists PATCH, otherwise POST.
|
||||
// Use a conflict-retry pattern to handle concurrent scrapes racing to insert
|
||||
// the same slug: if POST fails (or another concurrent writer beat us to it),
|
||||
// re-fetch and PATCH instead.
|
||||
existing, err := s.getBookBySlug(ctx, meta.Slug)
|
||||
if err != nil && err != ErrNotFound {
|
||||
return fmt.Errorf("WriteMetadata: %w", err)
|
||||
}
|
||||
if err == ErrNotFound {
|
||||
return s.pb.post(ctx, "/api/collections/books/records", payload, nil)
|
||||
postErr := s.pb.post(ctx, "/api/collections/books/records", payload, nil)
|
||||
if postErr == nil {
|
||||
return nil
|
||||
}
|
||||
// POST failed — a concurrent writer may have inserted the same slug.
|
||||
// Re-fetch and fall through to PATCH.
|
||||
existing, err = s.getBookBySlug(ctx, meta.Slug)
|
||||
if err != nil {
|
||||
return postErr // original POST error is more informative
|
||||
}
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload)
|
||||
}
|
||||
@@ -376,6 +388,10 @@ func (s *Store) AudioObjectKey(slug string, n int, voice string) string {
|
||||
return AudioObjectKey(slug, n, voice)
|
||||
}
|
||||
|
||||
func (s *Store) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||
return AudioObjectKeyExt(slug, n, voice, ext)
|
||||
}
|
||||
|
||||
func (s *Store) AudioExists(ctx context.Context, key string) bool {
|
||||
return s.mc.objectExists(ctx, s.mc.bucketAudio, key)
|
||||
}
|
||||
@@ -574,6 +590,28 @@ func (s *Store) CancelTask(ctx context.Context, id string) error {
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)})
|
||||
}
|
||||
|
||||
func (s *Store) CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error) {
|
||||
filter := fmt.Sprintf(`slug='%s'&&(status='pending'||status='running')`, slug)
|
||||
items, err := s.pb.listAll(ctx, "audio_jobs", filter, "")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("CancelAudioTasksBySlug list: %w", err)
|
||||
}
|
||||
cancelled := 0
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
if patchErr := s.pb.patch(ctx,
|
||||
fmt.Sprintf("/api/collections/audio_jobs/records/%s", rec.ID),
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)}); patchErr == nil {
|
||||
cancelled++
|
||||
}
|
||||
}
|
||||
}
|
||||
return cancelled, nil
|
||||
}
|
||||
|
||||
// ── taskqueue.Consumer ────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ClaimNextScrapeTask(ctx context.Context, workerID string) (domain.ScrapeTask, bool, error) {
|
||||
|
||||
@@ -36,6 +36,10 @@ type Producer interface {
|
||||
// CancelTask transitions a pending task to status=cancelled.
|
||||
// Returns ErrNotFound if the task does not exist.
|
||||
CancelTask(ctx context.Context, id string) error
|
||||
|
||||
// CancelAudioTasksBySlug cancels all pending or running audio tasks for slug.
|
||||
// Returns the number of tasks cancelled.
|
||||
CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error)
|
||||
}
|
||||
|
||||
// Consumer is the read/claim side of the task queue used by the runner.
|
||||
|
||||
@@ -26,7 +26,8 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string
|
||||
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
||||
return "translation-1", nil
|
||||
}
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
func (s *stubStore) CancelAudioTasksBySlug(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
|
||||
return domain.ScrapeTask{ID: "task-1", Status: domain.TaskStatusRunning}, true, nil
|
||||
|
||||
@@ -258,8 +258,26 @@ export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]>
|
||||
// Build filter: slug='a' || slug='b' || ...
|
||||
const filter = slugArr.map((s) => `slug='${s.replace(/'/g, "\\'")}'`).join(' || ');
|
||||
const books = await listAll<Book>('books', filter, '+title');
|
||||
log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length });
|
||||
return books;
|
||||
|
||||
// Deduplicate by slug — PocketBase may have multiple records for the same
|
||||
// slug if the scraper ran concurrently or the upsert raced. First record wins.
|
||||
const seen = new Set<string>();
|
||||
const deduped = books.filter((b) => {
|
||||
if (seen.has(b.slug)) return false;
|
||||
seen.add(b.slug);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (deduped.length !== books.length) {
|
||||
log.warn('pocketbase', 'getBooksBySlugs: duplicate slugs in DB', {
|
||||
requested: slugArr.length,
|
||||
raw: books.length,
|
||||
deduped: deduped.length
|
||||
});
|
||||
} else {
|
||||
log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/** Invalidate the books cache (call after a book is created/updated/deleted). */
|
||||
@@ -1644,3 +1662,110 @@ export async function getSubscriptionFeed(
|
||||
feed.sort((a, b) => b.updated.localeCompare(a.updated));
|
||||
return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername }));
|
||||
}
|
||||
|
||||
// ─── Discovery ────────────────────────────────────────────────────────────────
|
||||
// NOTE: Requires a `discovery_votes` collection in PocketBase with fields:
|
||||
// - session_id (text, required)
|
||||
// - user_id (text, optional)
|
||||
// - slug (text, required)
|
||||
// - action (text, required) — one of: like | skip | nope | read_now
|
||||
|
||||
export interface DiscoveryVote {
|
||||
id?: string;
|
||||
session_id: string;
|
||||
user_id?: string;
|
||||
slug: string;
|
||||
action: 'like' | 'skip' | 'nope' | 'read_now';
|
||||
}
|
||||
|
||||
export interface DiscoveryPrefs {
|
||||
genres: string[];
|
||||
status: 'either' | 'ongoing' | 'completed';
|
||||
}
|
||||
|
||||
function parseGenresLocal(genres: string[] | string): string[] {
|
||||
if (Array.isArray(genres)) return genres;
|
||||
if (!genres) return [];
|
||||
try { return JSON.parse(genres) as string[]; } catch { return []; }
|
||||
}
|
||||
|
||||
function discoveryFilter(sessionId: string, userId?: string): string {
|
||||
if (userId) return `user_id="${userId}"`;
|
||||
return `session_id="${sessionId}"`;
|
||||
}
|
||||
|
||||
export async function getVotedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||||
const rows = await listAll<DiscoveryVote>(
|
||||
'discovery_votes',
|
||||
discoveryFilter(sessionId, userId)
|
||||
).catch(() => [] as DiscoveryVote[]);
|
||||
return new Set(rows.map((r) => r.slug));
|
||||
}
|
||||
|
||||
export async function upsertDiscoveryVote(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
action: DiscoveryVote['action'],
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const filter = userId
|
||||
? `user_id="${userId}"&&slug="${slug}"`
|
||||
: `session_id="${sessionId}"&&slug="${slug}"`;
|
||||
const existing = await listOne<DiscoveryVote & { id: string }>('discovery_votes', filter);
|
||||
const payload: Partial<DiscoveryVote> = { session_id: sessionId, slug, action };
|
||||
if (userId) payload.user_id = userId;
|
||||
|
||||
if (existing) {
|
||||
const res = await pbPatch(`/api/collections/discovery_votes/records/${existing.id}`, payload);
|
||||
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote PATCH failed', { slug, status: res.status });
|
||||
} else {
|
||||
const res = await pbPost('/api/collections/discovery_votes/records', payload);
|
||||
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise<void> {
|
||||
const filter = discoveryFilter(sessionId, userId);
|
||||
const rows = await listAll<DiscoveryVote & { id: string }>('discovery_votes', filter).catch(() => []);
|
||||
await Promise.all(
|
||||
rows.map((r) =>
|
||||
pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBooksForDiscovery(
|
||||
sessionId: string,
|
||||
userId?: string,
|
||||
prefs?: DiscoveryPrefs
|
||||
): Promise<Book[]> {
|
||||
const [allBooks, votedSlugs, savedSlugs] = await Promise.all([
|
||||
listBooks(),
|
||||
getVotedSlugs(sessionId, userId),
|
||||
getSavedSlugs(sessionId, userId)
|
||||
]);
|
||||
|
||||
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
|
||||
|
||||
if (prefs?.genres?.length) {
|
||||
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
|
||||
const genreFiltered = candidates.filter((b) => {
|
||||
const genres = parseGenresLocal(b.genres);
|
||||
return genres.some((g) => preferred.has(g.toLowerCase()));
|
||||
});
|
||||
if (genreFiltered.length >= 5) candidates = genreFiltered;
|
||||
}
|
||||
|
||||
if (prefs?.status && prefs.status !== 'either') {
|
||||
const sf = candidates.filter((b) => b.status?.toLowerCase().includes(prefs.status));
|
||||
if (sf.length >= 3) candidates = sf;
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = candidates.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
|
||||
}
|
||||
|
||||
return candidates.slice(0, 50);
|
||||
}
|
||||
|
||||
@@ -302,6 +302,12 @@
|
||||
>
|
||||
{m.nav_library()}
|
||||
</a>
|
||||
<a
|
||||
href="/discover"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/discover') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Discover
|
||||
</a>
|
||||
<a
|
||||
href="/catalogue"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
@@ -478,6 +484,13 @@
|
||||
>
|
||||
{m.nav_library()}
|
||||
</a>
|
||||
<a
|
||||
href="/discover"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/discover') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Discover
|
||||
</a>
|
||||
<a
|
||||
href="/catalogue"
|
||||
onclick={() => (menuOpen = false)}
|
||||
|
||||
32
ui/src/routes/api/discover/vote/+server.ts
Normal file
32
ui/src/routes/api/discover/vote/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { upsertDiscoveryVote, clearDiscoveryVotes, saveBook } from '$lib/server/pocketbase';
|
||||
|
||||
const VALID_ACTIONS = ['like', 'skip', 'nope', 'read_now'] as const;
|
||||
type Action = (typeof VALID_ACTIONS)[number];
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body || typeof body.slug !== 'string' || !VALID_ACTIONS.includes(body.action)) {
|
||||
error(400, 'Expected { slug, action }');
|
||||
}
|
||||
const action = body.action as Action;
|
||||
try {
|
||||
await upsertDiscoveryVote(locals.sessionId, body.slug, action, locals.user?.id);
|
||||
if (action === 'like' || action === 'read_now') {
|
||||
await saveBook(locals.sessionId, body.slug, locals.user?.id);
|
||||
}
|
||||
} catch (e) {
|
||||
error(500, 'Failed to record vote');
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
try {
|
||||
await clearDiscoveryVotes(locals.sessionId, locals.user?.id);
|
||||
} catch {
|
||||
error(500, 'Failed to clear votes');
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
@@ -42,11 +42,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
case 'subscription.updated':
|
||||
case 'subscription.canceled':
|
||||
case 'subscription.revoked':
|
||||
await handleSubscriptionEvent(type, data as Parameters<typeof handleSubscriptionEvent>[1]);
|
||||
await handleSubscriptionEvent(type, data as unknown as Parameters<typeof handleSubscriptionEvent>[1]);
|
||||
break;
|
||||
|
||||
case 'order.created':
|
||||
await handleOrderCreated(data as Parameters<typeof handleOrderCreated>[0]);
|
||||
await handleOrderCreated(data as unknown as Parameters<typeof handleOrderCreated>[0]);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
14
ui/src/routes/discover/+page.server.ts
Normal file
14
ui/src/routes/discover/+page.server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBooksForDiscovery } from '$lib/server/pocketbase';
|
||||
import type { DiscoveryPrefs } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
let prefs: DiscoveryPrefs | undefined;
|
||||
const prefsParam = url.searchParams.get('prefs');
|
||||
if (prefsParam) {
|
||||
try { prefs = JSON.parse(prefsParam) as DiscoveryPrefs; } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const books = await getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []);
|
||||
return { books };
|
||||
};
|
||||
600
ui/src/routes/discover/+page.svelte
Normal file
600
ui/src/routes/discover/+page.svelte
Normal file
@@ -0,0 +1,600 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
import type { Book } from '$lib/server/pocketbase';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Preferences (localStorage) ──────────────────────────────────────────────
|
||||
interface Prefs {
|
||||
genres: string[];
|
||||
status: 'either' | 'ongoing' | 'completed';
|
||||
onboarded: boolean;
|
||||
}
|
||||
const PREFS_KEY = 'discover_prefs_v1';
|
||||
const GENRES = [
|
||||
'Martial Arts', 'Xianxia', 'Xuanhuan', 'Wuxia', 'Cultivation',
|
||||
'Romance', 'Action', 'Adventure', 'Fantasy', 'System',
|
||||
'Harem', 'Historical', 'Comedy', 'Drama', 'Mystery',
|
||||
'Sci-Fi', 'Horror', 'Slice of Life'
|
||||
];
|
||||
|
||||
function loadPrefs(): Prefs {
|
||||
if (!browser) return { genres: [], status: 'either', onboarded: false };
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_KEY);
|
||||
if (raw) return JSON.parse(raw) as Prefs;
|
||||
} catch { /* ignore */ }
|
||||
return { genres: [], status: 'either', onboarded: false };
|
||||
}
|
||||
function savePrefs(p: Prefs) {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(p));
|
||||
}
|
||||
|
||||
let prefs = $state<Prefs>(loadPrefs());
|
||||
let showOnboarding = $state(!prefs.onboarded);
|
||||
|
||||
// Onboarding temp state
|
||||
let tempGenres = $state<string[]>([...prefs.genres]);
|
||||
let tempStatus = $state<Prefs['status']>(prefs.status);
|
||||
|
||||
function finishOnboarding(skip = false) {
|
||||
if (!skip) {
|
||||
prefs = { genres: tempGenres, status: tempStatus, onboarded: true };
|
||||
} else {
|
||||
prefs = { ...prefs, onboarded: true };
|
||||
}
|
||||
savePrefs(prefs);
|
||||
showOnboarding = false;
|
||||
}
|
||||
|
||||
// ── Book deck (client-side filtered) ───────────────────────────────────────
|
||||
function parseBookGenres(genres: string[] | string): string[] {
|
||||
if (Array.isArray(genres)) return genres;
|
||||
if (!genres) return [];
|
||||
try { return JSON.parse(genres) as string[]; } catch { return []; }
|
||||
}
|
||||
|
||||
let deck = $derived.by(() => {
|
||||
let books = data.books as Book[];
|
||||
if (prefs.onboarded && prefs.genres.length > 0) {
|
||||
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
|
||||
const filtered = books.filter((b) => {
|
||||
const g = parseBookGenres(b.genres);
|
||||
return g.some((genre) => preferred.has(genre.toLowerCase()));
|
||||
});
|
||||
if (filtered.length >= 5) books = filtered;
|
||||
}
|
||||
if (prefs.onboarded && prefs.status !== 'either') {
|
||||
const sf = books.filter((b) => b.status?.toLowerCase().includes(prefs.status));
|
||||
if (sf.length >= 3) books = sf;
|
||||
}
|
||||
return books;
|
||||
});
|
||||
|
||||
// ── Card state ──────────────────────────────────────────────────────────────
|
||||
let idx = $state(0);
|
||||
let isDragging = $state(false);
|
||||
let animating = $state(false);
|
||||
let offsetX = $state(0);
|
||||
let offsetY = $state(0);
|
||||
let transitioning = $state(false);
|
||||
let showPreview = $state(false);
|
||||
let voted = $state<{ slug: string; action: string } | null>(null); // last voted, for undo
|
||||
|
||||
let startX = 0, startY = 0, hasMoved = false;
|
||||
|
||||
let currentBook = $derived(deck[idx] as Book | undefined);
|
||||
let nextBook = $derived(deck[idx + 1] as Book | undefined);
|
||||
let nextNextBook = $derived(deck[idx + 2] as Book | undefined);
|
||||
let deckEmpty = $derived(!currentBook);
|
||||
let totalRemaining = $derived(Math.max(0, deck.length - idx));
|
||||
|
||||
// Which direction/indicator to show
|
||||
let indicator = $derived.by((): 'like' | 'skip' | 'read_now' | 'nope' | null => {
|
||||
if (!isDragging) return null;
|
||||
const ax = Math.abs(offsetX), ay = Math.abs(offsetY);
|
||||
const threshold = 20;
|
||||
if (ax > ay) {
|
||||
if (offsetX > threshold) return 'like';
|
||||
if (offsetX < -threshold) return 'skip';
|
||||
} else {
|
||||
if (offsetY < -threshold) return 'read_now';
|
||||
if (offsetY > threshold) return 'nope';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const indicatorOpacity = $derived(
|
||||
isDragging
|
||||
? Math.min(1, Math.max(Math.abs(offsetX), Math.abs(offsetY)) / 60)
|
||||
: 0
|
||||
);
|
||||
const rotation = $derived(isDragging ? Math.max(-18, Math.min(18, offsetX / 12)) : 0);
|
||||
|
||||
let cardEl: HTMLDivElement | null = null;
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (animating || !currentBook) return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
hasMoved = false;
|
||||
isDragging = true;
|
||||
transitioning = false;
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging) return;
|
||||
offsetX = e.clientX - startX;
|
||||
offsetY = e.clientY - startY;
|
||||
if (Math.abs(offsetX) > 5 || Math.abs(offsetY) > 5) hasMoved = true;
|
||||
}
|
||||
|
||||
async function onPointerUp(e: PointerEvent) {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
|
||||
const THRESHOLD_X = 90;
|
||||
const THRESHOLD_Y = 70;
|
||||
const ax = Math.abs(offsetX), ay = Math.abs(offsetY);
|
||||
|
||||
if (ax > ay && ax > THRESHOLD_X) {
|
||||
await doAction(offsetX > 0 ? 'like' : 'skip');
|
||||
} else if (ay > ax && ay > THRESHOLD_Y) {
|
||||
await doAction(offsetY < 0 ? 'read_now' : 'nope');
|
||||
} else if (!hasMoved) {
|
||||
// Tap without drag → preview
|
||||
showPreview = true;
|
||||
offsetX = 0; offsetY = 0;
|
||||
} else {
|
||||
// Snap back
|
||||
transitioning = true;
|
||||
offsetX = 0; offsetY = 0;
|
||||
await delay(320);
|
||||
transitioning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number) { return new Promise<void>((r) => setTimeout(r, ms)); }
|
||||
|
||||
type VoteAction = 'like' | 'skip' | 'nope' | 'read_now';
|
||||
|
||||
const flyTargets: Record<VoteAction, { x: number; y: number }> = {
|
||||
like: { x: 1300, y: -80 },
|
||||
skip: { x: -1300, y: -80 },
|
||||
read_now: { x: 30, y: -1300 },
|
||||
nope: { x: 0, y: 1300 }
|
||||
};
|
||||
|
||||
async function doAction(action: VoteAction) {
|
||||
if (animating || !currentBook) return;
|
||||
animating = true;
|
||||
const book = currentBook;
|
||||
|
||||
// Record vote (fire and forget)
|
||||
fetch('/api/discover/vote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: book.slug, action })
|
||||
});
|
||||
|
||||
// Fly out
|
||||
transitioning = true;
|
||||
const target = flyTargets[action];
|
||||
offsetX = target.x;
|
||||
offsetY = target.y;
|
||||
|
||||
await delay(360);
|
||||
|
||||
// Advance
|
||||
voted = { slug: book.slug, action };
|
||||
idx++;
|
||||
transitioning = false;
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
animating = false;
|
||||
showPreview = false;
|
||||
|
||||
if (action === 'read_now') {
|
||||
goto(`/books/${book.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDeck() {
|
||||
await fetch('/api/discover/vote', { method: 'DELETE' });
|
||||
idx = 0;
|
||||
// Reload page to get fresh server data
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- ── Onboarding modal ───────────────────────────────────────────────────────── -->
|
||||
{#if showOnboarding}
|
||||
<div class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div class="w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="mb-5">
|
||||
<h2 class="text-xl font-bold text-(--color-text) mb-1">What do you like to read?</h2>
|
||||
<p class="text-sm text-(--color-muted)">We'll show you books you'll actually enjoy. Skip to see everything.</p>
|
||||
</div>
|
||||
|
||||
<!-- Genre pills -->
|
||||
<div class="mb-5">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-3">Genres</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each GENRES as genre}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (tempGenres.includes(genre)) {
|
||||
tempGenres = tempGenres.filter((g) => g !== genre);
|
||||
} else {
|
||||
tempGenres = [...tempGenres, genre];
|
||||
}
|
||||
}}
|
||||
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-all
|
||||
{tempGenres.includes(genre)
|
||||
? 'bg-(--color-brand) text-(--color-surface) border-(--color-brand)'
|
||||
: 'bg-(--color-surface-3) text-(--color-muted) border-transparent hover:border-(--color-border) hover:text-(--color-text)'}"
|
||||
>
|
||||
{genre}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-3">Status</p>
|
||||
<div class="flex gap-2">
|
||||
{#each (['either', 'ongoing', 'completed'] as const) as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (tempStatus = s)}
|
||||
class="flex-1 py-2 rounded-xl text-sm font-medium border transition-all
|
||||
{tempStatus === s
|
||||
? 'bg-(--color-brand) text-(--color-surface) border-(--color-brand)'
|
||||
: 'bg-(--color-surface-3) text-(--color-muted) border-transparent hover:text-(--color-text)'}"
|
||||
>
|
||||
{s === 'either' ? 'Either' : s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => finishOnboarding(true)}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => finishOnboarding(false)}
|
||||
class="flex-[2] py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Start Discovering
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Preview modal ───────────────────────────────────────────────────────────── -->
|
||||
{#if showPreview && currentBook}
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-4"
|
||||
onclick={() => (showPreview = false)}
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div
|
||||
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Cover strip -->
|
||||
<div class="relative h-40 overflow-hidden">
|
||||
{#if currentBook.cover}
|
||||
<img src={currentBook.cover} alt={currentBook.title} class="w-full h-full object-cover object-top" />
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-(--color-surface-2)"></div>
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-12 h-12 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<h3 class="font-bold text-(--color-text) text-lg leading-snug mb-1">{currentBook.title}</h3>
|
||||
{#if currentBook.author}
|
||||
<p class="text-sm text-(--color-muted) mb-3">{currentBook.author}</p>
|
||||
{/if}
|
||||
{#if currentBook.summary}
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{currentBook.summary}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2 mb-5">
|
||||
{#each parseBookGenres(currentBook.genres).slice(0, 4) as genre}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
{#if currentBook.status}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-text)">{currentBook.status}</span>
|
||||
{/if}
|
||||
{#if currentBook.total_chapters}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{currentBook.total_chapters} ch.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showPreview = false; doAction('skip'); }}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-danger) transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showPreview = false; doAction('read_now'); }}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-bold bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
|
||||
>
|
||||
Read Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showPreview = false; doAction('like'); }}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-bold bg-(--color-success)/20 text-(--color-success) hover:bg-(--color-success)/30 transition-colors"
|
||||
>
|
||||
Add ♥
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Main layout ────────────────────────────────────────────────────────────── -->
|
||||
<div class="min-h-screen bg-(--color-surface) flex flex-col items-center px-4 pt-8 pb-6 select-none">
|
||||
<!-- Header -->
|
||||
<div class="w-full max-w-sm flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-(--color-text)">Discover</h1>
|
||||
{#if !deckEmpty}
|
||||
<p class="text-xs text-(--color-muted)">{totalRemaining} books left</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
|
||||
title="Preferences"
|
||||
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if deckEmpty}
|
||||
<!-- Empty state -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
|
||||
<div class="w-20 h-20 rounded-full bg-(--color-surface-2) flex items-center justify-center text-4xl">
|
||||
📚
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-(--color-text) mb-2">All caught up!</h2>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
You've seen all available books.
|
||||
{#if prefs.genres.length > 0}
|
||||
Try adjusting your preferences to see more.
|
||||
{:else}
|
||||
Check your library for books you liked.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<a href="/books" class="py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) text-center hover:bg-(--color-brand-dim) transition-colors">
|
||||
My Library
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={resetDeck}
|
||||
class="py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
Start over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Card stack -->
|
||||
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.2;">
|
||||
|
||||
<!-- Back card 2 -->
|
||||
{#if nextNextBook}
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-lg"
|
||||
style="transform: scale(0.90) translateY(26px); z-index: 1; transition: transform 0.35s ease;"
|
||||
>
|
||||
{#if nextNextBook.cover}
|
||||
<img src={nextNextBook.cover} alt="" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Back card 1 -->
|
||||
{#if nextBook}
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-xl"
|
||||
style="transform: scale(0.95) translateY(13px); z-index: 2; transition: transform 0.35s ease;"
|
||||
>
|
||||
{#if nextBook.cover}
|
||||
<img src={nextBook.cover} alt="" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Active card -->
|
||||
<div
|
||||
bind:this={cardEl}
|
||||
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
|
||||
style="
|
||||
transform: translateX({offsetX}px) translateY({offsetY}px) rotate({rotation}deg);
|
||||
transition: {(transitioning && !isDragging) ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' : 'none'};
|
||||
touch-action: none;
|
||||
"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<!-- Cover image -->
|
||||
{#if currentBook.cover}
|
||||
<img src={currentBook.cover} alt={currentBook.title} class="w-full h-full object-cover pointer-events-none" draggable="false" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center pointer-events-none">
|
||||
<svg class="w-16 h-16 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom gradient + info -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent pointer-events-none"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-5 pointer-events-none">
|
||||
<h2 class="text-white font-bold text-xl leading-snug line-clamp-2 mb-1">{currentBook.title}</h2>
|
||||
{#if currentBook.author}
|
||||
<p class="text-white/70 text-sm mb-2">{currentBook.author}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-1.5 items-center">
|
||||
{#each parseBookGenres(currentBook.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs bg-white/15 text-white/90 px-2 py-0.5 rounded-full backdrop-blur-sm">{genre}</span>
|
||||
{/each}
|
||||
{#if currentBook.status}
|
||||
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{currentBook.status}</span>
|
||||
{/if}
|
||||
{#if currentBook.total_chapters}
|
||||
<span class="text-xs text-white/50 ml-auto">{currentBook.total_chapters} ch.</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIKE indicator (right swipe) -->
|
||||
<div
|
||||
class="absolute top-8 right-6 px-3 py-1.5 rounded-lg border-2 border-green-400 rotate-[-15deg] pointer-events-none"
|
||||
style="opacity: {indicator === 'like' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
|
||||
>
|
||||
<span class="text-green-400 font-black text-lg tracking-widest">LIKE</span>
|
||||
</div>
|
||||
|
||||
<!-- SKIP indicator (left swipe) -->
|
||||
<div
|
||||
class="absolute top-8 left-6 px-3 py-1.5 rounded-lg border-2 border-red-400 rotate-[15deg] pointer-events-none"
|
||||
style="opacity: {indicator === 'skip' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
|
||||
>
|
||||
<span class="text-red-400 font-black text-lg tracking-widest">SKIP</span>
|
||||
</div>
|
||||
|
||||
<!-- READ NOW indicator (swipe up) -->
|
||||
<div
|
||||
class="absolute top-8 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg border-2 border-blue-400 pointer-events-none"
|
||||
style="opacity: {indicator === 'read_now' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
|
||||
>
|
||||
<span class="text-blue-400 font-black text-lg tracking-widest">READ NOW</span>
|
||||
</div>
|
||||
|
||||
<!-- NOPE indicator (swipe down) -->
|
||||
<div
|
||||
class="absolute bottom-28 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg border-2 border-(--color-muted) pointer-events-none"
|
||||
style="opacity: {indicator === 'nope' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
|
||||
>
|
||||
<span class="text-(--color-muted) font-black text-lg tracking-widest">NOPE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="w-full max-w-sm flex items-center justify-center gap-4 mt-6">
|
||||
<!-- Skip (left) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => doAction('skip')}
|
||||
disabled={animating}
|
||||
title="Skip"
|
||||
class="w-14 h-14 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-red-400 hover:bg-red-400/10 hover:border-red-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Read Now (up) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => doAction('read_now')}
|
||||
disabled={animating}
|
||||
title="Read Now"
|
||||
class="w-12 h-12 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-blue-400 hover:bg-blue-400/10 hover:border-blue-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Preview (center) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPreview = true)}
|
||||
disabled={animating}
|
||||
title="Details"
|
||||
class="w-10 h-10 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Like (right) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => doAction('like')}
|
||||
disabled={animating}
|
||||
title="Add to Library"
|
||||
class="w-14 h-14 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-success) hover:bg-green-400/10 hover:border-green-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Nope (down) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => doAction('nope')}
|
||||
disabled={animating}
|
||||
title="Never show again"
|
||||
class="w-12 h-12 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-muted) hover:text-(--color-muted)/60 hover:bg-(--color-surface-3) hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Swipe hint (shown briefly) -->
|
||||
<p class="mt-4 text-xs text-(--color-muted)/50 text-center">
|
||||
Swipe or tap buttons · Tap card for details
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user