Compare commits
1 commit
6d391587c8
...
c5caf1128a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5caf1128a |
12 changed files with 133 additions and 4019 deletions
|
|
@ -1,5 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from .models import EBook, EBookProgress
|
||||
|
||||
admin.site.register(EBook)
|
||||
admin.site.register(EBookProgress)
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BooksConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'books'
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
# Generated by Django 6.0.3 on 2026-03-19 09:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EBook',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('meta_ct', models.TextField()),
|
||||
('meta_iv', models.CharField(max_length=32)),
|
||||
('data_ct', models.TextField()),
|
||||
('data_iv', models.CharField(max_length=32)),
|
||||
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebooks', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['uploaded_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EBookProgress',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('scroll_fraction', models.FloatField(default=0.0)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to='books.ebook')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebook_progress', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'book')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# Generated by Django 6.0.3 on 2026-03-19 11:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EBookBookmarks',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ct', models.TextField()),
|
||||
('iv', models.CharField(max_length=32)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookmarks', to='books.ebook')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebook_bookmarks', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'book')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EBookHighlights',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ct', models.TextField()),
|
||||
('iv', models.CharField(max_length=32)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='highlights', to='books.ebook')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ebook_highlights', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'book')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
||||
class EBook(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebooks')
|
||||
meta_ct = models.TextField() # base64 AES-GCM ciphertext of {title, author, filename}
|
||||
meta_iv = models.CharField(max_length=32) # hex IV for metadata
|
||||
data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes
|
||||
data_iv = models.CharField(max_length=32) # hex IV for EPUB data
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['uploaded_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"EBook #{self.pk} (user={self.user_id})"
|
||||
|
||||
|
||||
class EBookProgress(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress')
|
||||
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress')
|
||||
scroll_fraction = models.FloatField(default=0.0)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'book')
|
||||
|
||||
def __str__(self):
|
||||
return f"Progress book={self.book_id} user={self.user_id} {self.scroll_fraction:.2f}"
|
||||
|
||||
|
||||
class EBookHighlights(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_highlights')
|
||||
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='highlights')
|
||||
ct = models.TextField() # base64 AES-GCM ciphertext of JSON array
|
||||
iv = models.CharField(max_length=32) # hex IV
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'book')
|
||||
|
||||
def __str__(self):
|
||||
return f"Highlights book={self.book_id} user={self.user_id}"
|
||||
|
||||
|
||||
class EBookBookmarks(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_bookmarks')
|
||||
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='bookmarks')
|
||||
ct = models.TextField()
|
||||
iv = models.CharField(max_length=32)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'book')
|
||||
|
||||
def __str__(self):
|
||||
return f"Bookmarks book={self.book_id} user={self.user_id}"
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.book_list, name='book_list'),
|
||||
path('upload/', views.upload_book, name='upload_book'),
|
||||
path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
|
||||
path('<int:pk>/delete/', views.delete_book, name='delete_book'),
|
||||
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
|
||||
path('<int:pk>/highlights/', views.book_highlights, name='book_highlights'),
|
||||
path('<int:pk>/bookmarks/', views.book_bookmarks, name='book_bookmarks'),
|
||||
]
|
||||
225
books/views.py
225
books/views.py
|
|
@ -1,225 +0,0 @@
|
|||
import base64
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from .models import EBook, EBookProgress, EBookHighlights, EBookBookmarks
|
||||
|
||||
|
||||
def _require_auth(request):
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
return None
|
||||
|
||||
|
||||
@require_http_methods(['GET'])
|
||||
def book_list(request):
|
||||
err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
books = list(
|
||||
request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at')
|
||||
)
|
||||
for b in books:
|
||||
b['uploaded_at'] = b['uploaded_at'].isoformat()
|
||||
# Include saved scroll_fraction for each book
|
||||
progress_map = {
|
||||
p.book_id: p.scroll_fraction
|
||||
for p in EBookProgress.objects.filter(user=request.user)
|
||||
}
|
||||
for b in books:
|
||||
b['scroll_fraction'] = progress_map.get(b['id'], 0.0)
|
||||
return JsonResponse(books, safe=False)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['POST'])
|
||||
def upload_book(request):
|
||||
err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
|
||||
meta_ct = body.get('meta_ct', '')
|
||||
meta_iv = body.get('meta_iv', '')
|
||||
data_ct = body.get('data_ct', '')
|
||||
data_iv = body.get('data_iv', '')
|
||||
|
||||
if not all([meta_ct, meta_iv, data_ct, data_iv]):
|
||||
return JsonResponse({'error': 'meta_ct, meta_iv, data_ct, data_iv required'}, status=400)
|
||||
|
||||
# Enforce size limit: ciphertext is plaintext + 16-byte GCM tag
|
||||
max_bytes = getattr(settings, 'EBOOK_MAX_BYTES', 10 * 1024 * 1024) + 32
|
||||
try:
|
||||
raw_size = len(base64.b64decode(data_ct))
|
||||
except Exception:
|
||||
return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400)
|
||||
|
||||
if raw_size > max_bytes:
|
||||
return JsonResponse({'error': 'file too large (max 10 MB)'}, status=400)
|
||||
|
||||
book = EBook.objects.create(
|
||||
user=request.user,
|
||||
meta_ct=meta_ct,
|
||||
meta_iv=meta_iv,
|
||||
data_ct=data_ct,
|
||||
data_iv=data_iv,
|
||||
)
|
||||
return JsonResponse({'ok': True, 'id': book.id})
|
||||
|
||||
|
||||
@require_http_methods(['GET'])
|
||||
def get_book_data(request, pk):
|
||||
err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
try:
|
||||
book = EBook.objects.get(pk=pk, user=request.user)
|
||||
except EBook.DoesNotExist:
|
||||
return JsonResponse({'error': 'not found'}, status=404)
|
||||
|
||||
return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['POST'])
|
||||
def delete_book(request, pk):
|
||||
err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
try:
|
||||
book = EBook.objects.get(pk=pk, user=request.user)
|
||||
except EBook.DoesNotExist:
|
||||
return JsonResponse({'error': 'not found'}, status=404)
|
||||
|
||||
book.delete()
|
||||
return JsonResponse({'ok': True})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['POST'])
|
||||
def save_progress(request, pk):
|
||||
err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
try:
|
||||
book = EBook.objects.get(pk=pk, user=request.user)
|
||||
except EBook.DoesNotExist:
|
||||
return JsonResponse({'error': 'not found'}, status=404)
|
||||
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
|
||||
scroll_fraction = float(body.get('scroll_fraction', 0.0))
|
||||
scroll_fraction = max(0.0, min(1.0, scroll_fraction))
|
||||
|
||||
progress, _ = EBookProgress.objects.get_or_create(
|
||||
user=request.user,
|
||||
book=book,
|
||||
)
|
||||
progress.scroll_fraction = scroll_fraction
|
||||
progress.save(update_fields=['scroll_fraction', 'updated_at'])
|
||||
|
||||
return JsonResponse({'ok': True})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['GET', 'POST'])
|
||||
def book_highlights(request, pk):
|
||||
err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
try:
|
||||
book = EBook.objects.get(pk=pk, user=request.user)
|
||||
except EBook.DoesNotExist:
|
||||
return JsonResponse({'error': 'not found'}, status=404)
|
||||
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
row = EBookHighlights.objects.get(user=request.user, book=book)
|
||||
return JsonResponse({'ct': row.ct, 'iv': row.iv})
|
||||
except EBookHighlights.DoesNotExist:
|
||||
return JsonResponse({'ct': None, 'iv': None})
|
||||
|
||||
# POST — upsert
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
|
||||
ct = body.get('ct', '')
|
||||
iv = body.get('iv', '')
|
||||
if not ct or not iv:
|
||||
return JsonResponse({'error': 'ct and iv required'}, status=400)
|
||||
|
||||
# Size guard: highlights ≤ 700 KB base64
|
||||
try:
|
||||
raw_size = len(base64.b64decode(ct))
|
||||
except Exception:
|
||||
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
|
||||
if raw_size > 700 * 1024:
|
||||
return JsonResponse({'error': 'highlights data too large (max 700 KB)'}, status=400)
|
||||
|
||||
row, _ = EBookHighlights.objects.get_or_create(user=request.user, book=book)
|
||||
row.ct = ct
|
||||
row.iv = iv
|
||||
row.save(update_fields=['ct', 'iv', 'updated_at'])
|
||||
return JsonResponse({'ok': True})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['GET', 'POST'])
|
||||
def book_bookmarks(request, pk):
|
||||
err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
try:
|
||||
book = EBook.objects.get(pk=pk, user=request.user)
|
||||
except EBook.DoesNotExist:
|
||||
return JsonResponse({'error': 'not found'}, status=404)
|
||||
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
row = EBookBookmarks.objects.get(user=request.user, book=book)
|
||||
return JsonResponse({'ct': row.ct, 'iv': row.iv})
|
||||
except EBookBookmarks.DoesNotExist:
|
||||
return JsonResponse({'ct': None, 'iv': None})
|
||||
|
||||
# POST — upsert
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
||||
|
||||
ct = body.get('ct', '')
|
||||
iv = body.get('iv', '')
|
||||
if not ct or not iv:
|
||||
return JsonResponse({'error': 'ct and iv required'}, status=400)
|
||||
|
||||
# Size guard: bookmarks ≤ 100 KB base64
|
||||
try:
|
||||
raw_size = len(base64.b64decode(ct))
|
||||
except Exception:
|
||||
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
|
||||
if raw_size > 100 * 1024:
|
||||
return JsonResponse({'error': 'bookmarks data too large (max 100 KB)'}, status=400)
|
||||
|
||||
row, _ = EBookBookmarks.objects.get_or_create(user=request.user, book=book)
|
||||
row.ct = ct
|
||||
row.iv = iv
|
||||
row.save(update_fields=['ct', 'iv', 'updated_at'])
|
||||
return JsonResponse({'ok': True})
|
||||
|
|
@ -32,13 +32,8 @@
|
|||
--text-muted: var(--fg);
|
||||
}
|
||||
|
||||
/* inverted: black on white */
|
||||
/* inverted: black on bright */
|
||||
body.bright-bg {
|
||||
--bg: #fff;
|
||||
--bg-card: #f5f5f5;
|
||||
--bg-alt: #f0f0f0;
|
||||
--bg-row: #fafafa;
|
||||
--bg-row-alt: #f5f5f5;
|
||||
--fg: #000;
|
||||
--fg-muted: #000;
|
||||
--outline: #fff;
|
||||
|
|
@ -319,22 +314,6 @@ a:hover {
|
|||
min-height: 200px;
|
||||
}
|
||||
|
||||
.sub-tabs {
|
||||
border-bottom-color: transparent;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sub-tabs .tab-btn {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sub-tabs .tab-btn.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
SEARCH
|
||||
========================================================= */
|
||||
|
|
@ -1020,570 +999,3 @@ body.dnd-mode .timer-display {
|
|||
.volume-num::-webkit-inner-spin-button,
|
||||
.volume-num::-webkit-outer-spin-button { display: none; }
|
||||
.volume-num { -moz-appearance: textfield; }
|
||||
|
||||
/* ===== PODCAST COMPONENTS ===== */
|
||||
|
||||
.podcast-seek-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skip-btn {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.skip-btn:hover { opacity: 1; }
|
||||
|
||||
.speed-btns {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.speed-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #444);
|
||||
color: var(--fg, #fff);
|
||||
border-radius: 3px;
|
||||
font-size: 0.68rem;
|
||||
padding: 1px 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.speed-btn:hover { opacity: 1; }
|
||||
.speed-btn.active {
|
||||
opacity: 1;
|
||||
border-color: var(--accent, #e63946);
|
||||
color: var(--accent, #e63946);
|
||||
}
|
||||
|
||||
.seek-slider {
|
||||
flex: 1;
|
||||
accent-color: var(--accent, #e63946);
|
||||
cursor: pointer;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.seek-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #888);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.podcast-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 0 12px;
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.podcast-pane { }
|
||||
|
||||
.podcast-feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.podcast-feed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border, #222);
|
||||
}
|
||||
|
||||
.podcast-feed-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.podcast-feed-title {
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.podcast-feed-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.podcast-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.podcast-thumb-lg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.podcast-thumb-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--surface, #1e1e2e);
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.podcast-unplayed-badge {
|
||||
background: var(--accent, #e63946);
|
||||
color: #fff;
|
||||
border-radius: 99px;
|
||||
padding: 1px 7px;
|
||||
font-size: 0.7rem;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.episode-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.episode-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--border, #222);
|
||||
}
|
||||
|
||||
.episode-item.episode-played {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.episode-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.episode-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.episode-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.podcast-search-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.podcast-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border, #222);
|
||||
}
|
||||
|
||||
.podcast-search-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.podcast-queue-ol {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.podcast-feed-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.podcast-feed-header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Clickable episode title (episode list) */
|
||||
.ep-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ep-clickable:hover {
|
||||
color: var(--accent, #e63946);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Clickable episode title / feed name in the now-playing bar */
|
||||
.podcast-track-link,
|
||||
.podcast-station-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.podcast-track-link:hover,
|
||||
.podcast-station-link:hover {
|
||||
color: var(--accent, #e63946);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ===== SIDEBAR ===== */
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 299;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
max-width: 100vw;
|
||||
/* stop above the now-playing bar */
|
||||
bottom: 72px;
|
||||
background: var(--surface, #111);
|
||||
border-left: 1px solid var(--border, #333);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.22s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
flex-shrink: 0;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.sidebar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Style links and basic HTML inside shownotes */
|
||||
.sidebar-body a { color: var(--accent, #e63946); }
|
||||
.sidebar-body p { margin: 0 0 10px; }
|
||||
.sidebar-body ul, .sidebar-body ol { margin: 0 0 10px; padding-left: 20px; }
|
||||
.sidebar-body h1, .sidebar-body h2, .sidebar-body h3 {
|
||||
font-size: 0.9rem;
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.sidebar { width: 100vw; }
|
||||
.podcast-seek-bar { padding: 0 6px; }
|
||||
.podcast-thumb { width: 40px; height: 40px; }
|
||||
.podcast-thumb-lg { width: 60px; height: 60px; }
|
||||
.podcast-feed-actions { flex-direction: column; }
|
||||
.episode-actions { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Ebook reader + book list + focus station
|
||||
========================================================= */
|
||||
|
||||
/* --- Drop zone --- */
|
||||
.book-drop-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
margin: 12px 0;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.book-drop-zone:hover,
|
||||
.book-drop-zone.drag-over {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
.book-drop-zone label {
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.book-key-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* --- Book list --- */
|
||||
.book-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.book-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
gap: 12px;
|
||||
}
|
||||
.book-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.book-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.book-author,
|
||||
.book-progress {
|
||||
font-size: 12px;
|
||||
}
|
||||
.book-item-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* --- PDF pages --- */
|
||||
.pdf-page-wrapper {
|
||||
margin: 0 auto 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pdf-page {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
/* --- Reader overlay --- */
|
||||
.reader-overlay {
|
||||
position: fixed;
|
||||
top: var(--nav-h);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--bar-h);
|
||||
background: var(--bg);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.reader-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
.reader-title {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
.reader-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reader-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.reader-content {
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
padding: 24px 16px;
|
||||
line-height: 1.8;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.reader-content > * {
|
||||
max-width: 65ch;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.reader-content p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.reader-content h1, .reader-content h2, .reader-content h3 {
|
||||
margin: 1.4em 0 0.6em;
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
/* --- Focus station sidebar --- */
|
||||
.focus-preset-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.focus-preset-list li.focus-preset-active button {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.focus-custom-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* --- Table of contents sidebar --- */
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.toc-entry {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
.toc-entry:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Focus station pending (playing interrupted) */
|
||||
#focus-station-btn.focus-pending {
|
||||
color: var(--accent);
|
||||
animation: focus-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes focus-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Reader feature additions
|
||||
========================================================= */
|
||||
|
||||
/* Reader themes */
|
||||
.reader-theme-sepia .reader-content { background:#f5e6c8; color:#3b2a1a; }
|
||||
.reader-theme-bright .reader-content { background:#fff; color:#111; }
|
||||
.reader-content > * { max-width:var(--reader-max-width,65ch); margin-left:auto; margin-right:auto; }
|
||||
|
||||
/* Inline panels */
|
||||
.reader-settings-panel, .reader-search-bar {
|
||||
display:flex; align-items:center; gap:12px; padding:8px 16px;
|
||||
border-bottom:1px solid var(--border); flex-shrink:0; flex-wrap:wrap; background:var(--bg);
|
||||
}
|
||||
.reader-settings-panel input[type="range"] { width:80px; }
|
||||
|
||||
/* PDF inner container — shares origin with canvas so text layer aligns exactly */
|
||||
.pdf-page-inner { position:relative; display:inline-block; line-height:0; }
|
||||
|
||||
/* PDF text layer — sits flush over the canvas inside pdf-page-inner */
|
||||
.pdf-text-layer { position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }
|
||||
.pdf-text-layer span { position:absolute; color:transparent !important; background:transparent; white-space:pre; cursor:text; pointer-events:auto; user-select:text; -webkit-user-select:text; line-height:1; }
|
||||
.pdf-text-layer span::selection { background:rgba(0,120,255,0.3); }
|
||||
.pdf-text-layer span.reader-search-match { background:rgba(241,196,15,.5); }
|
||||
.pdf-text-layer span.reader-search-match.active { background:rgba(230,57,70,.6); }
|
||||
|
||||
/* PDF invert */
|
||||
#reader-overlay.pdf-inverted .pdf-page { filter:invert(1); }
|
||||
|
||||
/* PDF paginated */
|
||||
.reader-content.pdf-paginated { overflow:hidden !important; display:flex; align-items:center; justify-content:center; }
|
||||
.pdf-paginated .pdf-page-wrapper { margin:0; }
|
||||
|
||||
/* Highlight popover */
|
||||
.highlight-popover { position:fixed; z-index:500; display:flex; gap:6px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.5); }
|
||||
.hl-color-btn { width:24px; height:24px; border-radius:50%; border:2px solid transparent; cursor:pointer; font-weight:700; font-size:12px; color:#000; line-height:1; }
|
||||
.hl-color-btn:hover { border-color:#fff; }
|
||||
.hl-note-btn { background:none; border:1px solid var(--border); color:var(--fg); padding:2px 6px; border-radius:var(--radius); cursor:pointer; }
|
||||
|
||||
/* Highlight marks */
|
||||
.epub-highlight { border-radius:2px; cursor:pointer; }
|
||||
.epub-highlight[data-color="yellow"] { background:rgba(241,196,15,.4); }
|
||||
.epub-highlight[data-color="green"] { background:rgba(46,204,113,.35); }
|
||||
.epub-highlight[data-color="blue"] { background:rgba(52,152,219,.35); }
|
||||
.epub-highlight[data-color="red"] { background:rgba(230,57,70,.35); }
|
||||
|
||||
/* Search matches */
|
||||
mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border-radius:2px; }
|
||||
mark.reader-search-match.active { background:rgba(230,57,70,.7); }
|
||||
#rs-search-count { font-size:12px; min-width:50px; }
|
||||
|
||||
/* Bookmarks sidebar */
|
||||
.bookmark-entry { display:flex; width:100%; padding:6px 0; font-size:13px; justify-content:space-between; border-bottom:1px solid var(--border); }
|
||||
|
||||
/* Toast */
|
||||
.reader-toast { position:fixed; bottom:calc(var(--bar-h) + 16px); left:50%; transform:translateX(-50%); background:var(--fg); color:var(--bg); padding:6px 14px; border-radius:var(--radius); font-size:13px; z-index:600; animation:toast-fade 2s ease forwards; pointer-events:none; }
|
||||
@keyframes toast-fade { 0%,70%{opacity:1} 100%{opacity:0} }
|
||||
|
|
|
|||
2808
static/js/app.js
2808
static/js/app.js
File diff suppressed because it is too large
Load diff
|
|
@ -18,21 +18,6 @@
|
|||
</label>
|
||||
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Save</button>
|
||||
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)">⊙</button>
|
||||
<button class="btn-icon" id="focus-station-btn" onclick="openFocusStationSidebar()" title="Focus station">📻</button>
|
||||
</div>
|
||||
<div class="podcast-seek-bar" id="podcast-seek-bar" style="display:none;">
|
||||
<button class="btn-icon skip-btn" onclick="skipBack()" title="Back 15s">⏪ 15</button>
|
||||
<span class="seek-time" id="seek-current">0:00</span>
|
||||
<input type="range" id="seek-slider" class="seek-slider" min="0" max="100" value="0">
|
||||
<span class="seek-time" id="seek-duration">0:00</span>
|
||||
<button class="btn-icon skip-btn" onclick="skipForward()" title="Forward 30s">30 ⏩</button>
|
||||
<div class="speed-btns" id="speed-btns">
|
||||
<button class="speed-btn" onclick="setPlaybackRate(0.75)">¾×</button>
|
||||
<button class="speed-btn active" onclick="setPlaybackRate(1)">1×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(1.25)">1¼×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(1.5)">1½×</button>
|
||||
<button class="speed-btn" onclick="setPlaybackRate(2)">2×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timer-widget" id="timer-widget">
|
||||
<span class="timer-phase" id="timer-phase-label">focus</span>
|
||||
|
|
@ -59,149 +44,140 @@
|
|||
|
||||
<!-- ===== TABS ===== -->
|
||||
<div class="tabs" id="tabs">
|
||||
<button class="tab-btn active" onclick="showTab('radio')">Radio</button>
|
||||
<button class="tab-btn active" onclick="showTab('search')">Search</button>
|
||||
<button class="tab-btn" onclick="showTab('saved')">Saved</button>
|
||||
<button class="tab-btn" onclick="showTab('history')">History</button>
|
||||
<button class="tab-btn" onclick="showTab('focus')">Focus</button>
|
||||
<button class="tab-btn" onclick="showTab('podcasts')">Podcasts</button>
|
||||
<button class="tab-btn" onclick="showTab('books')">Books</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== RADIO TAB ===== -->
|
||||
<section class="tab-panel" id="tab-radio">
|
||||
<div class="tabs sub-tabs" id="radio-sub-tabs">
|
||||
<button class="tab-btn active" onclick="showRadioTab('search')">Search</button>
|
||||
<button class="tab-btn" onclick="showRadioTab('saved')">Saved</button>
|
||||
<button class="tab-btn" onclick="showRadioTab('history')">History</button>
|
||||
<!-- ===== SEARCH TAB ===== -->
|
||||
<section class="tab-panel" id="tab-search">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info…" onkeydown="if(event.key==='Enter') doSearch()">
|
||||
<button class="btn" onclick="doSearch()">Search</button>
|
||||
</div>
|
||||
<div class="mood-chips" id="mood-chips"></div>
|
||||
<div id="curated-lists" class="curated-lists-container"></div>
|
||||
<div id="search-status" class="status-msg"></div>
|
||||
<table class="data-table" id="search-results-table" style="display:none;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Bitrate</th>
|
||||
<th>Country</th>
|
||||
<th>Tags</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="search-results-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- ===== SEARCH SUB-PANEL ===== -->
|
||||
<div class="sub-tab-panel" id="tab-search">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" class="search-input" placeholder="Search radio-browser.info…" onkeydown="if(event.key==='Enter') doSearch()">
|
||||
<button class="btn" onclick="doSearch()">Search</button>
|
||||
<!-- ===== SAVED TAB ===== -->
|
||||
<section class="tab-panel" id="tab-saved" style="display:none;">
|
||||
{% if featured_stations %}
|
||||
<div class="featured-section">
|
||||
<p class="featured-label">★ Featured</p>
|
||||
<ul class="featured-list">
|
||||
{% for s in featured_stations %}
|
||||
<li>
|
||||
{% if s.favicon_url %}<img src="{{ s.favicon_url }}" class="station-favicon" alt="">{% endif %}
|
||||
<button class="btn btn-sm" onclick="playStation('{{ s.url|escapejs }}', '{{ s.name|escapejs }}', null)">
|
||||
▶ {{ s.name }}
|
||||
</button>
|
||||
{% if s.description %}<span class="muted">{{ s.description }}</span>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
<div id="recommendations" class="recommendations-section">
|
||||
<!-- populated by JS -->
|
||||
</div>
|
||||
<div class="mood-chips" id="mood-chips"></div>
|
||||
<div id="curated-lists" class="curated-lists-container"></div>
|
||||
<div id="search-status" class="status-msg"></div>
|
||||
<table class="data-table" id="search-results-table" style="display:none;">
|
||||
<div class="import-bar">
|
||||
<label class="btn btn-sm" for="m3u-file-input" title="Import .m3u / .m3u8 from the desktop app">
|
||||
⇧ Import M3U
|
||||
</label>
|
||||
<input type="file" id="m3u-file-input" accept=".m3u,.m3u8" style="display:none;" onchange="importM3U(this)">
|
||||
<span id="import-status" class="muted"></span>
|
||||
</div>
|
||||
<table class="data-table" id="saved-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>★</th>
|
||||
<th>Name</th>
|
||||
<th>Bitrate</th>
|
||||
<th>Country</th>
|
||||
<th>Tags</th>
|
||||
<th title="Notes">✎</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="search-results-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ===== SAVED SUB-PANEL ===== -->
|
||||
<div class="sub-tab-panel" id="tab-saved" style="display:none;">
|
||||
{% if featured_stations %}
|
||||
<div class="featured-section">
|
||||
<p class="featured-label">★ Featured</p>
|
||||
<ul class="featured-list">
|
||||
{% for s in featured_stations %}
|
||||
<li>
|
||||
{% if s.favicon_url %}<img src="{{ s.favicon_url }}" class="station-favicon" alt="">{% endif %}
|
||||
<button class="btn btn-sm" onclick="playStation('{{ s.url|escapejs }}', '{{ s.name|escapejs }}', null)">
|
||||
▶ {{ s.name }}
|
||||
</button>
|
||||
{% if s.description %}<span class="muted">{{ s.description }}</span>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
<div id="recommendations" class="recommendations-section">
|
||||
<!-- populated by JS -->
|
||||
</div>
|
||||
<div class="import-bar">
|
||||
<label class="btn btn-sm" for="m3u-file-input" title="Import .m3u / .m3u8 from the desktop app">
|
||||
⇧ Import M3U
|
||||
</label>
|
||||
<input type="file" id="m3u-file-input" accept=".m3u,.m3u8" style="display:none;" onchange="importM3U(this)">
|
||||
<span id="import-status" class="muted"></span>
|
||||
</div>
|
||||
<table class="data-table" id="saved-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>★</th>
|
||||
<th>Name</th>
|
||||
<th>Bitrate</th>
|
||||
<th>Country</th>
|
||||
<th title="Notes">✎</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="saved-tbody">
|
||||
{% for station in saved_stations %}
|
||||
<tr id="saved-row-{{ station.id }}" data-id="{{ station.id }}" data-url="{{ station.url }}" data-name="{{ station.name }}">
|
||||
<td>
|
||||
<button class="btn-icon fav-btn {% if station.is_favorite %}active{% endif %}"
|
||||
onclick="toggleFav({{ station.id }})"
|
||||
title="Toggle favorite">★</button>
|
||||
</td>
|
||||
<td class="station-name-cell">{{ station.name }}</td>
|
||||
<td>{{ station.bitrate }}</td>
|
||||
<td>{{ station.country }}</td>
|
||||
<td class="notes-cell" onclick="editNotes({{ station.id }}, this.textContent.trim())" title="{{ station.notes|default:'' }}" style="cursor:pointer; color:#666; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ station.notes }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm"
|
||||
onclick="playStation('{{ station.url }}', '{{ station.name|escapejs }}', {{ station.id }})">
|
||||
▶ Play
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
onclick="removeStation({{ station.id }})">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr id="saved-empty-row"><td colspan="7" class="empty-msg">No saved stations yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="auth-prompt">
|
||||
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||
to save stations and sync across devices.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ===== HISTORY SUB-PANEL ===== -->
|
||||
<div class="sub-tab-panel" id="tab-history" style="display:none;">
|
||||
<table class="data-table" id="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Station</th>
|
||||
<th>Track</th>
|
||||
<th>♬</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-tbody">
|
||||
{% for entry in history %}
|
||||
<tr data-id="{{ entry.id }}">
|
||||
<td class="history-time">{{ entry.played_at|slice:":16"|cut:"T" }}</td>
|
||||
<td>{{ entry.station_name }}</td>
|
||||
<td>{{ entry.track }}</td>
|
||||
<td>{% if entry.scrobbled %}<span title="Scrobbled to Last.fm">✓</span>{% endif %}</td>
|
||||
<td><button class="btn-delete-history" onclick="deleteHistoryEntry({{ entry.id }}, this)" title="Remove">✕</button></td>
|
||||
<tbody id="saved-tbody">
|
||||
{% for station in saved_stations %}
|
||||
<tr id="saved-row-{{ station.id }}" data-id="{{ station.id }}" data-url="{{ station.url }}" data-name="{{ station.name }}">
|
||||
<td>
|
||||
<button class="btn-icon fav-btn {% if station.is_favorite %}active{% endif %}"
|
||||
onclick="toggleFav({{ station.id }})"
|
||||
title="Toggle favorite">★</button>
|
||||
</td>
|
||||
<td class="station-name-cell">{{ station.name }}</td>
|
||||
<td>{{ station.bitrate }}</td>
|
||||
<td>{{ station.country }}</td>
|
||||
<td class="notes-cell" onclick="editNotes({{ station.id }}, this.textContent.trim())" title="{{ station.notes|default:'' }}" style="cursor:pointer; color:#666; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ station.notes }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm"
|
||||
onclick="playStation('{{ station.url }}', '{{ station.name|escapejs }}', {{ station.id }})">
|
||||
▶ Play
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
onclick="removeStation({{ station.id }})">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr id="history-empty-row"><td colspan="5" class="empty-msg">No history yet.</td></tr>
|
||||
<tr id="saved-empty-row"><td colspan="7" class="empty-msg">No saved stations yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="auth-prompt">
|
||||
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||
to save stations and sync across devices.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- ===== HISTORY TAB ===== -->
|
||||
<section class="tab-panel" id="tab-history" style="display:none;">
|
||||
<table class="data-table" id="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Station</th>
|
||||
<th>Track</th>
|
||||
<th>♬</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-tbody">
|
||||
{% for entry in history %}
|
||||
<tr data-id="{{ entry.id }}">
|
||||
<td class="history-time">{{ entry.played_at|slice:":16"|cut:"T" }}</td>
|
||||
<td>{{ entry.station_name }}</td>
|
||||
<td>{{ entry.track }}</td>
|
||||
<td>{% if entry.scrobbled %}<span title="Scrobbled to Last.fm">✓</span>{% endif %}</td>
|
||||
<td><button class="btn-delete-history" onclick="deleteHistoryEntry({{ entry.id }}, this)" title="Remove">✕</button></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr id="history-empty-row"><td colspan="5" class="empty-msg">No history yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- ===== FOCUS TAB ===== -->
|
||||
|
|
@ -220,108 +196,6 @@
|
|||
</table>
|
||||
</section>
|
||||
|
||||
<!-- ===== PODCASTS TAB ===== -->
|
||||
<section class="tab-panel" id="tab-podcasts" style="display:none;">
|
||||
{% if user.is_authenticated %}
|
||||
<div class="podcast-toolbar">
|
||||
<button class="btn btn-sm" onclick="showPodcastView('feeds')">Feeds</button>
|
||||
<button class="btn btn-sm" onclick="showPodcastView('inbox')">Inbox</button>
|
||||
<button class="btn btn-sm" onclick="showPodcastView('queue')">Queue</button>
|
||||
<button class="btn btn-sm" onclick="podcastSearchOpen()">+ Search</button>
|
||||
<label class="btn btn-sm" for="opml-file-input">Import OPML</label>
|
||||
<input type="file" id="opml-file-input" accept=".opml,.xml" style="display:none;" onchange="importOPML(this)">
|
||||
<span id="opml-status" class="muted"></span>
|
||||
</div>
|
||||
|
||||
<!-- Search pane -->
|
||||
<div class="podcast-pane" id="podcast-search-pane" style="display:none;">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="podcast-search-input" class="search-input" placeholder="Search podcasts…"
|
||||
onkeydown="if(event.key==='Enter') doPodcastSearch()">
|
||||
<button class="btn" onclick="doPodcastSearch()">Search</button>
|
||||
</div>
|
||||
<div id="podcast-search-status" class="status-msg"></div>
|
||||
<div id="podcast-search-list" class="podcast-search-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Feeds pane -->
|
||||
<div class="podcast-pane" id="podcast-feeds-pane">
|
||||
<div id="podcast-feed-list" class="podcast-feed-list">
|
||||
<p class="muted">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inbox pane -->
|
||||
<div class="podcast-pane" id="podcast-inbox-pane" style="display:none;">
|
||||
<div id="podcast-inbox-list" class="episode-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Episodes pane -->
|
||||
<div class="podcast-pane" id="podcast-episodes-pane" style="display:none;">
|
||||
<div id="podcast-feed-header" class="podcast-feed-header"></div>
|
||||
<div id="podcast-episode-list" class="episode-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Queue pane -->
|
||||
<div class="podcast-pane" id="podcast-queue-pane" style="display:none;">
|
||||
<ol id="podcast-queue-ol" class="podcast-queue-ol"></ol>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<p class="auth-prompt">
|
||||
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||
to subscribe to podcasts.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- ===== BOOKS TAB ===== -->
|
||||
<section class="tab-panel" id="tab-books" style="display:none;">
|
||||
{% if user.is_authenticated %}
|
||||
<div class="book-drop-zone" id="book-drop-zone">
|
||||
<span>Drop .epub or .pdf here or <label for="book-file-input" style="cursor:pointer;text-decoration:underline;">browse</label></span>
|
||||
<input type="file" id="book-file-input" accept=".epub,.pdf" style="display:none;" onchange="bookFileSelected(this)">
|
||||
<span id="book-upload-status" class="muted"></span>
|
||||
</div>
|
||||
<div id="book-list" class="book-list"></div>
|
||||
{% else %}
|
||||
<p class="auth-prompt">
|
||||
<a href="{% url 'login' %}">Log in</a> or <a href="{% url 'register' %}">register</a>
|
||||
to use the encrypted ebook reader.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- ===== READER OVERLAY ===== -->
|
||||
<div id="reader-overlay" class="reader-overlay" style="display:none;">
|
||||
<div class="reader-header">
|
||||
<span id="reader-title" class="reader-title"></span>
|
||||
<div class="reader-header-actions">
|
||||
<span class="reader-progress-wrap">
|
||||
<input type="number" id="reader-progress-input" class="volume-num" min="0" max="100" value="0" style="display:none;">
|
||||
<span id="reader-progress-suffix" class="muted"></span>
|
||||
</span>
|
||||
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search">🔍</button>
|
||||
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font & layout">⚙</button>
|
||||
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark">★</button>
|
||||
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks">☰</button>
|
||||
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents">≡</button>
|
||||
<button class="btn-icon" onclick="closeReader()" title="Close (Esc)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reader-content" class="reader-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- ===== SIDEBAR ===== -->
|
||||
<div id="sidebar-overlay" class="sidebar-overlay" onclick="closeSidebar()" style="display:none;"></div>
|
||||
<aside id="sidebar" class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span id="sidebar-title" class="sidebar-title"></span>
|
||||
<button class="btn-icon sidebar-close" onclick="closeSidebar()" title="Close">✕</button>
|
||||
</div>
|
||||
<div id="sidebar-body" class="sidebar-body"></div>
|
||||
</aside>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
|
|
@ -330,9 +204,6 @@
|
|||
const INITIAL_SAVED = {{ saved_stations|safe }};
|
||||
const INITIAL_FEATURED = {{ featured_stations|safe }};
|
||||
const IS_AUTHENTICATED = {{ user.is_authenticated|yesno:"true,false" }};
|
||||
const INITIAL_PODCAST_FEEDS = {{ initial_podcast_feeds|safe }};
|
||||
const USER_ID = {{ user.id|default:"null" }};
|
||||
let USER_FOCUS_STATION = {{ focus_station_json|safe }};
|
||||
</script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue