diff --git a/frontend-v2/src/lib/components/media/BookSearch.svelte b/frontend-v2/src/lib/components/media/BookSearch.svelte index da49d44..9191cc6 100644 --- a/frontend-v2/src/lib/components/media/BookSearch.svelte +++ b/frontend-v2/src/lib/components/media/BookSearch.svelte @@ -1,5 +1,6 @@ - -
- - -
- {#if kindleConfigured && kindleTargets.length > 0}
@@ -216,31 +314,29 @@
{/if} -{#if activeView === 'search'} - - {:else} - - {#if downloadCount === 0} -
No downloads yet
- {:else} -
- {#each Object.entries(downloads) as [id, dl] (id)} -
-
-
{dl.title || id}
- {#if dl.author}
{dl.author}
{/if} -
- {#if dl.format}{dl.format.toUpperCase()}{/if} - {dl.status} - {#if dl.status_message}{dl.status_message}{/if} -
- {#if dl.status === 'downloading' && dl.progress > 0} -
- {/if} -
-
- {#if dl.status === 'complete'} - {#if kindleSent.has(id)} - Sent to Kindle ✓ - {:else if kindleSending.has(id)} - Sending to Kindle... - {/if} - {#if importedIds.has(id)} - Imported ✓ - {:else if importingIds.has(id)} - Importing... - {:else} - {#if libraries.length > 0} - - {/if} - - {/if} - {:else if dl.status === 'error'} - - - {:else if ['queued', 'downloading', 'locating', 'resolving'].includes(dl.status)} - - {:else} - - {/if} -
-
- {/each} -
- {/if} +
+ +
Search for books
+
Anna's Archive, Libgen, Z-Library
+
{/if} - diff --git a/frontend-v2/src/lib/components/media/DownloadStatusDock.svelte b/frontend-v2/src/lib/components/media/DownloadStatusDock.svelte new file mode 100644 index 0000000..2bda2ea --- /dev/null +++ b/frontend-v2/src/lib/components/media/DownloadStatusDock.svelte @@ -0,0 +1,245 @@ + + +{#if items.length > 0} + +{/if} + + diff --git a/frontend-v2/src/lib/components/media/MusicSearch.svelte b/frontend-v2/src/lib/components/media/MusicSearch.svelte index 2ecd7a9..0565ab5 100644 --- a/frontend-v2/src/lib/components/media/MusicSearch.svelte +++ b/frontend-v2/src/lib/components/media/MusicSearch.svelte @@ -1,5 +1,6 @@ - -
- -
-{#if activeView === 'search'} - - - {#if searching} -
Searching Spotify...
- {:else if searched && results.length === 0} -
No results for "{query}"
- {:else if results.length > 0} - {#if searchType === 'track'} - {#if playingEmbed} -
- -
- {/if} -
- {#each results as track (track.id)} +{#if searching} +
Searching Spotify...
+{:else if searched && results.length === 0} +
No results for "{query}"
+{:else if results.length > 0} + {#if searchType === 'track'} + {#if playingEmbed} +
+ +
+ {/if} +
+ {#each results as track (track.id)} {@const art = albumArt(track)}
{/if}
- {/each} -
- {:else} -
- {#each results as item (item.id)} + {/each} +
+ {:else} +
+ {#each results as item (item.id)} {@const art = albumArt(item)}
@@ -152,124 +206,78 @@ {downloading.has(item.id) ? 'Queued' : 'Download'}
- {/each} -
- {/if} - {:else} -
- -
Search for music
-
Spotify tracks, albums, playlists
-
- {/if} -{:else} - - {#if tasks.length === 0} -
No music downloads
- {:else} -
- {#each tasks as task (task.task_id)} -
-
-
{task.name || task.task_id}
- {#if task.artist}
{task.artist}
{/if} -
- {task.status} - {#if task.completed_items != null && task.total_items}{task.completed_items}/{task.total_items} tracks{/if} - {#if task.speed}{task.speed}{/if} - {#if task.eta}ETA {task.eta}{/if} - {#if task.error_message}{task.error_message}{/if} -
- {#if task.progress != null && task.progress > 0} -
- {/if} -
- {#if ['downloading', 'queued'].includes(task.status)} - - {/if} -
{/each}
{/if} +{:else} +
+ +
Search for music
+
Spotify tracks, albums, playlists
+
{/if} - diff --git a/frontend-v2/src/routes/(app)/downloads/+page.svelte b/frontend-v2/src/routes/(app)/downloads/+page.svelte index a59f314..09cce3f 100644 --- a/frontend-v2/src/routes/(app)/downloads/+page.svelte +++ b/frontend-v2/src/routes/(app)/downloads/+page.svelte @@ -1,75 +1,5 @@ -
-
- - -
- - - -
- - {#if activeTab === 'books'} - - {:else if activeTab === 'music'} - - {:else} - - {/if} -
-
- - + diff --git a/frontend-v2/src/routes/mockup/downloads/+page.svelte b/frontend-v2/src/routes/mockup/downloads/+page.svelte new file mode 100644 index 0000000..09cce3f --- /dev/null +++ b/frontend-v2/src/routes/mockup/downloads/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 4306c92..c6b0c80 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -3,7 +3,7 @@ WORKDIR /app RUN pip install --no-cache-dir bcrypt RUN adduser --disabled-password --no-create-home appuser RUN mkdir -p /app/data && chown -R appuser /app/data -COPY --chown=appuser server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py assistant.py ./ +COPY --chown=appuser server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py assistant.py feedback.py ./ COPY --chown=appuser integrations/ ./integrations/ EXPOSE 8100 ENV PYTHONUNBUFFERED=1 diff --git a/gateway/auth.py b/gateway/auth.py index cbbf4bf..edf3a44 100644 --- a/gateway/auth.py +++ b/gateway/auth.py @@ -12,6 +12,7 @@ import sqlite3 import bcrypt +from config import SESSION_COOKIE_SECURE from database import get_db from sessions import create_session, delete_session @@ -58,7 +59,10 @@ def handle_logout(handler): delete_session(token) handler.send_response(200) handler.send_header("Content-Type", "application/json") - handler.send_header("Set-Cookie", "platform_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0") + cookie = "platform_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" + if SESSION_COOKIE_SECURE: + cookie += "; Secure" + handler.send_header("Set-Cookie", cookie) resp = b'{"success": true}' handler.send_header("Content-Length", len(resp)) handler.end_headers() diff --git a/gateway/config.py b/gateway/config.py index ccf75bf..c9638bb 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -62,6 +62,7 @@ OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.2") # ── Session config ── SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", 30 * 86400)) # 30 days +SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "").lower() in {"1", "true", "yes", "on"} DEV_AUTO_LOGIN = os.environ.get("DEV_AUTO_LOGIN", "").lower() in {"1", "true", "yes", "on"} DEV_AUTO_LOGIN_USERNAME = os.environ.get("DEV_AUTO_LOGIN_USERNAME", "dev") DEV_AUTO_LOGIN_DISPLAY_NAME = os.environ.get("DEV_AUTO_LOGIN_DISPLAY_NAME", "Dev User") diff --git a/gateway/feedback.py b/gateway/feedback.py new file mode 100644 index 0000000..37a061b --- /dev/null +++ b/gateway/feedback.py @@ -0,0 +1,108 @@ +""" +Platform Gateway — Feedback handler. +Creates Gitea issues from user feedback with auto-labeling. +""" + +import json +import re +import urllib.request +import urllib.error + +GITEA_URL = "http://localhost:3300" +GITEA_TOKEN = "7abae0245e49e130e1b6752b7fa381a57607d8c7" +REPO = "yusiboyz/platform" + +# Label IDs from Gitea +LABELS = { + "bug": 11, + "feature": 12, + "ios": 13, + "web": 14, + "fitness": 15, + "brain": 16, + "reader": 17, + "podcasts": 18, + "enhancement": 19, +} + + +def auto_label(text: str, source: str = "ios") -> list[int]: + """Detect labels from feedback text.""" + text_lower = text.lower() + label_ids = [] + + # Source platform + if source == "ios": + label_ids.append(LABELS["ios"]) + elif source == "web": + label_ids.append(LABELS["web"]) + + # Bug or feature + bug_words = ["bug", "broken", "crash", "error", "wrong", "fix", "issue", "doesn't work", "not working", "can't"] + feature_words = ["want", "add", "wish", "would be nice", "can we", "can you", "feature", "idea", "suggest"] + + if any(w in text_lower for w in bug_words): + label_ids.append(LABELS["bug"]) + elif any(w in text_lower for w in feature_words): + label_ids.append(LABELS["feature"]) + else: + label_ids.append(LABELS["enhancement"]) + + # App detection + if any(w in text_lower for w in ["fitness", "calorie", "food", "meal", "macro", "protein"]): + label_ids.append(LABELS["fitness"]) + elif any(w in text_lower for w in ["brain", "note", "save", "bookmark"]): + label_ids.append(LABELS["brain"]) + elif any(w in text_lower for w in ["reader", "rss", "feed", "article"]): + label_ids.append(LABELS["reader"]) + elif any(w in text_lower for w in ["podcast", "episode", "listen", "player"]): + label_ids.append(LABELS["podcasts"]) + + return label_ids + + +def handle_feedback(handler, body, user): + """Create a Gitea issue from user feedback.""" + try: + data = json.loads(body) + except Exception: + handler._send_json({"error": "Invalid JSON"}, 400) + return + + text = (data.get("text") or "").strip() + source = data.get("source", "ios") + + if not text: + handler._send_json({"error": "Feedback text is required"}, 400) + return + + user_name = user.get("display_name") or user.get("username", "Unknown") + label_ids = auto_label(text, source) + + # Create Gitea issue + issue_body = { + "title": text[:80] + ("..." if len(text) > 80 else ""), + "body": f"**From:** {user_name} ({source})\n\n{text}", + "labels": label_ids, + } + + try: + req = urllib.request.Request( + f"{GITEA_URL}/api/v1/repos/{REPO}/issues", + data=json.dumps(issue_body).encode(), + headers={ + "Authorization": f"token {GITEA_TOKEN}", + "Content-Type": "application/json", + }, + method="POST", + ) + resp = urllib.request.urlopen(req, timeout=10) + result = json.loads(resp.read()) + + handler._send_json({ + "ok": True, + "issue_number": result.get("number"), + "url": result.get("html_url"), + }) + except Exception as e: + handler._send_json({"error": f"Failed to create issue: {e}"}, 500) diff --git a/gateway/responses.py b/gateway/responses.py index b74a1e8..3a24761 100644 --- a/gateway/responses.py +++ b/gateway/responses.py @@ -5,7 +5,7 @@ Platform Gateway — Response helpers mixed into GatewayHandler. import json from http.cookies import SimpleCookie -from config import SESSION_MAX_AGE, DEV_AUTO_LOGIN +from config import SESSION_MAX_AGE, SESSION_COOKIE_SECURE, DEV_AUTO_LOGIN from sessions import get_session_user, get_or_create_dev_user @@ -43,8 +43,10 @@ class ResponseMixin: return user def _set_session_cookie(self, token): - self.send_header("Set-Cookie", - f"platform_session={token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={SESSION_MAX_AGE}") + cookie = f"platform_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_MAX_AGE}" + if SESSION_COOKIE_SECURE: + cookie += "; Secure" + self.send_header("Set-Cookie", cookie) def _read_body(self): length = int(self.headers.get("Content-Length", 0)) diff --git a/gateway/server.py b/gateway/server.py index 537b130..4f7a0af 100644 --- a/gateway/server.py +++ b/gateway/server.py @@ -25,6 +25,7 @@ from dashboard import ( handle_set_connection, handle_pin, handle_unpin, handle_get_pinned, ) from command import handle_command +from feedback import handle_feedback from assistant import handle_assistant, handle_fitness_assistant, handle_brain_assistant from integrations.booklore import ( handle_booklore_libraries, handle_booklore_import, @@ -243,6 +244,12 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler): handle_command(self, user, body) return + if path == "/api/feedback": + user = self._require_auth() + if user: + handle_feedback(self, body, user) + return + if path == "/api/assistant": user = self._require_auth() if user: diff --git a/ios/Platform/Platform.xcodeproj/project.pbxproj b/ios/Platform/Platform.xcodeproj/project.pbxproj index db7203c..5d2637c 100644 --- a/ios/Platform/Platform.xcodeproj/project.pbxproj +++ b/ios/Platform/Platform.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ A10030 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10030; }; A10031 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10031; }; A10032 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C10001; }; + A10033 /* FeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10034; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -74,6 +75,7 @@ B10030 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = ""; }; B10031 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; B10033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B10034 /* FeedbackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackView.swift; sourceTree = ""; }; C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -128,6 +130,7 @@ F10006 /* Home */, F10007 /* Fitness */, F10014 /* Assistant */, + F10020 /* Feedback */, ); path = Features; sourceTree = ""; @@ -221,6 +224,14 @@ path = Assistant; sourceTree = ""; }; + F10020 /* Feedback */ = { + isa = PBXGroup; + children = ( + B10034 /* FeedbackView.swift */, + ); + path = Feedback; + sourceTree = ""; + }; F10015 /* Shared */ = { isa = PBXGroup; children = ( @@ -357,6 +368,7 @@ A10029 /* LoadingView.swift in Sources */, A10030 /* Color+Extensions.swift in Sources */, A10031 /* Date+Extensions.swift in Sources */, + A10033 /* FeedbackView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Platform/Platform/ContentView.swift b/ios/Platform/Platform/ContentView.swift index 8b0409a..ca992cf 100644 --- a/ios/Platform/Platform/ContentView.swift +++ b/ios/Platform/Platform/ContentView.swift @@ -40,11 +40,17 @@ struct MainTabView: View { } .tint(Color.accentWarm) - // Floating + button + // Floating buttons VStack { Spacer() - HStack { + HStack(alignment: .bottom) { + // Feedback button (subtle, bottom-left) + FeedbackButton() + .padding(.leading, 20) + Spacer() + + // Add food button (prominent, bottom-right) Button { showAssistant = true } label: { Image(systemName: "plus") .font(.title2.weight(.semibold)) diff --git a/ios/Platform/Platform/Features/Feedback/FeedbackView.swift b/ios/Platform/Platform/Features/Feedback/FeedbackView.swift new file mode 100644 index 0000000..da29b01 --- /dev/null +++ b/ios/Platform/Platform/Features/Feedback/FeedbackView.swift @@ -0,0 +1,120 @@ +import SwiftUI + +struct FeedbackButton: View { + @State private var showSheet = false + + var body: some View { + Button { showSheet = true } label: { + Image(systemName: "exclamationmark.bubble.fill") + .font(.system(size: 14)) + .foregroundStyle(Color.textTertiary) + .frame(width: 32, height: 32) + .background(Color.surfaceCard.opacity(0.8)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.08), radius: 4, y: 2) + } + .sheet(isPresented: $showSheet) { + FeedbackSheet() + .presentationDetents([.medium]) + } + } +} + +struct FeedbackSheet: View { + @Environment(\.dismiss) private var dismiss + @State private var text = "" + @State private var isSending = false + @State private var sent = false + @State private var error: String? + + var body: some View { + VStack(spacing: 16) { + Capsule() + .fill(Color.textTertiary.opacity(0.3)) + .frame(width: 36, height: 5) + .padding(.top, 8) + + Text("Feedback") + .font(.headline) + .foregroundStyle(Color.textPrimary) + + Text("Bug report, feature request, or anything else") + .font(.caption) + .foregroundStyle(Color.textTertiary) + + if sent { + VStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(Color.emerald) + Text("Sent! We'll look into it.") + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.textPrimary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + TextEditor(text: $text) + .font(.body) + .scrollContentBackground(.hidden) + .padding(12) + .background(Color.canvas) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.textTertiary.opacity(0.2), lineWidth: 1) + ) + + if let error { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Button { + sendFeedback() + } label: { + if isSending { + ProgressView().tint(.white) + } else { + Text("Send") + .font(.headline) + } + } + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(text.trimmingCharacters(in: .whitespaces).isEmpty ? Color.textTertiary : Color.accentWarm) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .disabled(text.trimmingCharacters(in: .whitespaces).isEmpty || isSending) + } + } + .padding() + .background(Color.surfaceCard) + } + + private func sendFeedback() { + isSending = true + error = nil + + Task { + do { + let body: [String: String] = ["text": text.trimmingCharacters(in: .whitespaces), "source": "ios"] + let jsonData = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await APIClient.shared.rawPost("/api/feedback", body: jsonData) + + if let httpResp = response as? HTTPURLResponse, httpResp.statusCode < 300 { + withAnimation { sent = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + dismiss() + } + } else { + let respBody = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + error = (respBody?["error"] as? String) ?? "Failed to send" + } + } catch { + self.error = error.localizedDescription + } + isSending = false + } + } +} diff --git a/screenshots/ScreenRecording_04-03-2026 10-45-15_1.mp4 b/screenshots/ScreenRecording_04-03-2026 10-45-15_1.mp4 new file mode 100644 index 0000000..81741e4 Binary files /dev/null and b/screenshots/ScreenRecording_04-03-2026 10-45-15_1.mp4 differ