Searching Spotify...
+{:else if results.length > 0}
+ {#if searchType === 'track'}
+ {#if playingEmbed}
+
+ {#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