fifu-tui 1.4.5 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.SRCINFO +2 -2
- package/PKGBUILD +1 -1
- package/README.md +17 -7
- package/apps/api/package.json +14 -0
- package/apps/api/src/index.js +134 -0
- package/apps/mobile/.expo/README.md +13 -0
- package/apps/mobile/.expo/devices.json +3 -0
- package/apps/mobile/App.tsx +20 -0
- package/apps/mobile/README.md +21 -0
- package/apps/mobile/app.json +16 -0
- package/apps/mobile/babel.config.js +6 -0
- package/apps/mobile/index.js +5 -0
- package/apps/mobile/package.json +27 -0
- package/apps/mobile/src/api/client.ts +21 -0
- package/apps/mobile/src/components/Card.tsx +17 -0
- package/apps/mobile/src/components/Chip.tsx +42 -0
- package/apps/mobile/src/components/ListRow.tsx +41 -0
- package/apps/mobile/src/components/PrimaryButton.tsx +52 -0
- package/apps/mobile/src/components/ProgressBar.tsx +26 -0
- package/apps/mobile/src/components/SecondaryButton.tsx +36 -0
- package/apps/mobile/src/components/Section.tsx +23 -0
- package/apps/mobile/src/components/Toggle.tsx +37 -0
- package/apps/mobile/src/mock.ts +19 -0
- package/apps/mobile/src/navigation.tsx +53 -0
- package/apps/mobile/src/screens/DownloadsScreen.tsx +135 -0
- package/apps/mobile/src/screens/HomeScreen.tsx +187 -0
- package/apps/mobile/src/screens/LibraryScreen.tsx +102 -0
- package/apps/mobile/src/screens/OptionsScreen.tsx +189 -0
- package/apps/mobile/src/screens/ResultsScreen.tsx +82 -0
- package/apps/mobile/src/screens/SettingsScreen.tsx +74 -0
- package/apps/mobile/src/theme.ts +38 -0
- package/apps/mobile/tsconfig.json +6 -0
- package/design-system/fifu-mobile/MASTER.md +197 -0
- package/design-system/fifu-mobile/pages/mobile-app.md +24 -0
- package/docs/app/[[...slug]]/page.tsx +51 -0
- package/docs/app/api/search/route.ts +4 -0
- package/docs/app/components/download-stats.tsx +87 -0
- package/docs/app/components/theme-toggle.tsx +50 -0
- package/docs/app/globals.css +135 -0
- package/docs/app/layout.tsx +62 -0
- package/docs/content/docs/configuration.mdx +25 -0
- package/docs/content/docs/controls.mdx +18 -0
- package/docs/content/docs/downloads.mdx +35 -0
- package/docs/content/docs/faq.mdx +30 -0
- package/docs/content/docs/features.mdx +32 -0
- package/docs/content/docs/glossary.mdx +34 -0
- package/docs/content/docs/index.mdx +70 -0
- package/docs/content/docs/installation.mdx +56 -0
- package/docs/content/docs/meta.json +19 -0
- package/docs/content/docs/options.mdx +30 -0
- package/docs/content/docs/quickstart.mdx +20 -0
- package/docs/content/docs/troubleshooting.mdx +35 -0
- package/docs/content/docs/ui-tour.mdx +37 -0
- package/docs/content/docs/usage.mdx +38 -0
- package/docs/content/docs/workflow.mdx +35 -0
- package/docs/mobile-app-design.md +83 -0
- package/docs/next-env.d.ts +6 -0
- package/docs/next.config.mjs +11 -0
- package/docs/package.json +34 -0
- package/docs/pnpm-lock.yaml +6883 -0
- package/docs/public/screenshots/download.svg +12 -0
- package/docs/public/screenshots/options.svg +13 -0
- package/docs/public/screenshots/search.svg +12 -0
- package/docs/source.config.ts +10 -0
- package/docs/tailwind.config.ts +42 -0
- package/docs/tsconfig.json +46 -0
- package/fifu/app.py +247 -15
- package/fifu/screens/__init__.py +2 -1
- package/fifu/screens/channels.py +28 -22
- package/fifu/screens/download.py +30 -21
- package/fifu/screens/loading.py +64 -0
- package/fifu/screens/options.py +35 -0
- package/fifu/screens/search.py +71 -14
- package/fifu/screens/video_select.py +161 -0
- package/fifu/services/downloader.py +61 -10
- package/fifu/services/joke.py +29 -0
- package/fifu/services/youtube.py +25 -0
- package/fifu/styles/app.tcss +76 -1
- package/package.json +2 -1
- package/pnpm-workspace.yaml +2 -0
- package/pyproject.toml +1 -1
- package/verify_changes.py +50 -0
package/.SRCINFO
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
pkgbase = fifu
|
|
2
2
|
pkgdesc = A cross-platform TUI for downloading YouTube videos from channels
|
|
3
|
-
pkgver = 1.4.
|
|
3
|
+
pkgver = 1.4.6
|
|
4
4
|
pkgrel = 1
|
|
5
5
|
url = https://github.com/Dawaman43/fifu
|
|
6
6
|
arch = any
|
|
@@ -12,7 +12,7 @@ pkgbase = fifu
|
|
|
12
12
|
depends = python-textual
|
|
13
13
|
depends = yt-dlp
|
|
14
14
|
depends = python-click
|
|
15
|
-
source = https://github.com/Dawaman43/fifu/archive/refs/tags/v1.4.
|
|
15
|
+
source = https://github.com/Dawaman43/fifu/archive/refs/tags/v1.4.6.tar.gz
|
|
16
16
|
sha256sums = SKIP
|
|
17
17
|
|
|
18
18
|
pkgname = fifu
|
package/PKGBUILD
CHANGED
package/README.md
CHANGED
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**The Ultra-Fast, Cross-Platform TUI for Downloading YouTube Channel Content.**
|
|
4
4
|
|
|
5
|
-
[](https://github.com/Dawaman43/fifu/actions)
|
|
6
|
-
[](https://badge.fury.io/py/fifu)
|
|
7
|
-
[](https://aur.archlinux.org/packages/fifu)
|
|
8
|
-
[](https://opensource.org/licenses/MIT)
|
|
9
|
-
[](https://www.python.org/downloads/)
|
|
5
|
+
[](https://github.com/Dawaman43/fifu/actions) [](https://pypi.org/project/fifu/) [](https://www.npmjs.com/package/fifu-tui) [](https://aur.archlinux.org/packages/fifu) [](https://opensource.org/licenses/MIT) [](https://www.python.org/downloads/)
|
|
10
6
|
|
|
11
7
|
Fifu (Fetch It For Us) is a high-performance Terminal User Interface (TUI) designed for power users who want to download entire YouTube channels or playlists with zero friction. Built with **Textual** and powered by **yt-dlp**.
|
|
12
8
|
|
|
@@ -26,16 +22,30 @@ Fifu (Fetch It For Us) is a high-performance Terminal User Interface (TUI) desig
|
|
|
26
22
|
|
|
27
23
|
## 🚀 Quick Start
|
|
28
24
|
|
|
29
|
-
The fastest way to
|
|
25
|
+
The fastest way to run Fifu instantly is using **npx**:
|
|
30
26
|
|
|
31
27
|
```bash
|
|
32
|
-
|
|
28
|
+
npx fifu-tui
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Alternatively, you can install it via **pipx**:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pipx install git+https://github.com/Dawaman43/fifu.git --force
|
|
33
35
|
```
|
|
34
36
|
|
|
35
37
|
---
|
|
36
38
|
|
|
37
39
|
## 📦 Installation Options
|
|
38
40
|
|
|
41
|
+
### 📦 npm (JavaScript Wrapper)
|
|
42
|
+
|
|
43
|
+
If you prefer using npm, you can install Fifu globally:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm install -g fifu-tui
|
|
47
|
+
```
|
|
48
|
+
|
|
39
49
|
### 🐧 Linux
|
|
40
50
|
|
|
41
51
|
#### Arch Linux (AUR)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fifu-api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "node --watch src/index.js",
|
|
8
|
+
"start": "node src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@hono/node-server": "^1.14.4",
|
|
12
|
+
"hono": "^4.7.4"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { exec } from "child_process";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
const app = new Hono();
|
|
7
|
+
|
|
8
|
+
// Helper to format large numbers (mimics the Python implementation)
|
|
9
|
+
const formatCount = (count) => {
|
|
10
|
+
if (!count) return "N/A";
|
|
11
|
+
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
12
|
+
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
|
13
|
+
return count.toString();
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
app.get("/health", (c) => c.json({ ok: true }));
|
|
17
|
+
|
|
18
|
+
app.post("/api/search", async (c) => {
|
|
19
|
+
const body = await c.req.json().catch(() => ({}));
|
|
20
|
+
const query = body.query || "";
|
|
21
|
+
const type = body.type || "channel"; // "channel" or "video"
|
|
22
|
+
|
|
23
|
+
if (!query) return c.json({ query: "", channels: [], videos: [] });
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const searchPrefix = type === "video" ? "ytsearch50" : "ytsearch50";
|
|
27
|
+
const { stdout } = await execAsync(
|
|
28
|
+
`yt-dlp "${searchPrefix}:${query}" --flat-playlist --dump-single-json --quiet --no-warnings`
|
|
29
|
+
);
|
|
30
|
+
const data = JSON.parse(stdout);
|
|
31
|
+
|
|
32
|
+
const results = { query, type };
|
|
33
|
+
|
|
34
|
+
if (type === "video") {
|
|
35
|
+
results.videos = (data.entries || []).map(entry => ({
|
|
36
|
+
id: entry.id,
|
|
37
|
+
title: entry.title || "Unknown",
|
|
38
|
+
url: `https://www.youtube.com/watch?v=${entry.id}`,
|
|
39
|
+
duration: entry.duration,
|
|
40
|
+
uploader: entry.uploader || entry.channel || "Unknown",
|
|
41
|
+
thumbnail: entry.thumbnail
|
|
42
|
+
}));
|
|
43
|
+
} else {
|
|
44
|
+
const channels = [];
|
|
45
|
+
const seenIds = new Set();
|
|
46
|
+
if (data.entries) {
|
|
47
|
+
for (const entry of data.entries) {
|
|
48
|
+
if (entry.channel_id && !seenIds.has(entry.channel_id)) {
|
|
49
|
+
seenIds.add(entry.channel_id);
|
|
50
|
+
const subCount = entry.channel_follower_count || entry.follower_count || 0;
|
|
51
|
+
channels.push({
|
|
52
|
+
id: entry.channel_id,
|
|
53
|
+
name: entry.channel || entry.uploader || "Unknown",
|
|
54
|
+
url: `https://www.youtube.com/channel/${entry.channel_id}`,
|
|
55
|
+
subs: formatCount(subCount),
|
|
56
|
+
subCount: subCount,
|
|
57
|
+
description: entry.description ? entry.description.slice(0, 100) : ""
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
channels.sort((a, b) => b.subCount - a.subCount);
|
|
63
|
+
results.channels = channels;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return c.json(results);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error("Search error:", error);
|
|
69
|
+
return c.json({ query, channels: [], error: "Failed to fetch channels" }, 500);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
app.post("/api/options", async (c) => {
|
|
74
|
+
const body = await c.req.json().catch(() => ({}));
|
|
75
|
+
const channelId = body.channelId;
|
|
76
|
+
|
|
77
|
+
if (!channelId) return c.json({ error: "Missing channelId" }, 400);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Fetch playlists for the channel
|
|
81
|
+
const { stdout } = await execAsync(
|
|
82
|
+
`yt-dlp "https://www.youtube.com/channel/${channelId}/playlists" --flat-playlist --dump-single-json --quiet --no-warnings`
|
|
83
|
+
);
|
|
84
|
+
const data = JSON.parse(stdout);
|
|
85
|
+
|
|
86
|
+
const playlists = (data.entries || []).map(p => ({
|
|
87
|
+
id: p.id,
|
|
88
|
+
title: p.title || "Unknown Playlist",
|
|
89
|
+
url: p.url || `https://www.youtube.com/playlist?list=${p.id}`,
|
|
90
|
+
count: p.playlist_count || 0
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
return c.json({
|
|
94
|
+
channelId,
|
|
95
|
+
name: data.uploader || data.channel || "Unknown Channel",
|
|
96
|
+
playlists,
|
|
97
|
+
totalVideos: data.playlist_count || 0
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error("Options error:", error);
|
|
101
|
+
return c.json({ error: "Failed to fetch channel options" }, 500);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Mock job tracking for the mobile UI
|
|
106
|
+
app.get("/api/jobs/:id", (c) => {
|
|
107
|
+
const jobIds = ["job_demo", "a1", "a2"];
|
|
108
|
+
const id = c.req.param("id");
|
|
109
|
+
|
|
110
|
+
if (!jobIds.includes(id) && !id.startsWith("job_")) {
|
|
111
|
+
return c.json({ error: "Job not found" }, 404);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return c.json({
|
|
115
|
+
id,
|
|
116
|
+
status: id === "a1" ? "downloading" : "completed",
|
|
117
|
+
total: 10,
|
|
118
|
+
completed: id === "a1" ? 6 : 10,
|
|
119
|
+
progress: id === "a1" ? 60 : 100,
|
|
120
|
+
speed: id === "a1" ? "4.2 MB/s" : "0 B/s",
|
|
121
|
+
eta: id === "a1" ? "1m 20s" : "Done",
|
|
122
|
+
active: id === "a1" ? ["Video metadata...", "Part 2 of 10"] : []
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export default app;
|
|
127
|
+
|
|
128
|
+
// Node server bootstrap
|
|
129
|
+
if (process.env.NODE_ENV !== "test") {
|
|
130
|
+
const port = Number(process.env.PORT ?? 8787);
|
|
131
|
+
const { serve } = await import("@hono/node-server");
|
|
132
|
+
serve({ fetch: app.fetch, port });
|
|
133
|
+
console.log(`API running on http://localhost:${port}`);
|
|
134
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
> Why do I have a folder named ".expo" in my project?
|
|
2
|
+
|
|
3
|
+
The ".expo" folder is created when an Expo project is started using "expo start" command.
|
|
4
|
+
|
|
5
|
+
> What do the files contain?
|
|
6
|
+
|
|
7
|
+
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
|
|
8
|
+
- "settings.json": contains the server configuration that is used to serve the application manifest.
|
|
9
|
+
|
|
10
|
+
> Should I commit the ".expo" folder?
|
|
11
|
+
|
|
12
|
+
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
|
|
13
|
+
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { SafeAreaView, StyleSheet, StatusBar } from "react-native";
|
|
3
|
+
import { AppNavigation } from "./src/navigation";
|
|
4
|
+
import { COLORS } from "./src/theme";
|
|
5
|
+
|
|
6
|
+
export default function App() {
|
|
7
|
+
return (
|
|
8
|
+
<SafeAreaView style={styles.safeArea}>
|
|
9
|
+
<StatusBar barStyle="light-content" backgroundColor={COLORS.background} />
|
|
10
|
+
<AppNavigation />
|
|
11
|
+
</SafeAreaView>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const styles = StyleSheet.create({
|
|
16
|
+
safeArea: {
|
|
17
|
+
flex: 1,
|
|
18
|
+
backgroundColor: COLORS.background
|
|
19
|
+
}
|
|
20
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Fifu Mobile (Expo)
|
|
2
|
+
|
|
3
|
+
## Setup
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm install -r
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Run
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm --filter fifu-mobile start
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## API URL
|
|
16
|
+
|
|
17
|
+
Set the backend URL in your shell before running:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export EXPO_PUBLIC_API_URL=http://localhost:8787
|
|
21
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"expo": {
|
|
3
|
+
"name": "Fifu",
|
|
4
|
+
"slug": "fifu-mobile",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"orientation": "portrait",
|
|
7
|
+
"userInterfaceStyle": "light",
|
|
8
|
+
"splash": {
|
|
9
|
+
"resizeMode": "contain",
|
|
10
|
+
"backgroundColor": "#FDF2F3"
|
|
11
|
+
},
|
|
12
|
+
"assetBundlePatterns": [
|
|
13
|
+
"**/*"
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fifu-mobile",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "expo start",
|
|
8
|
+
"android": "expo start --android",
|
|
9
|
+
"ios": "expo start --ios",
|
|
10
|
+
"web": "expo start --web"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@react-navigation/bottom-tabs": "^6.5.20",
|
|
14
|
+
"@react-navigation/native": "^6.1.17",
|
|
15
|
+
"@react-navigation/native-stack": "^6.9.26",
|
|
16
|
+
"expo": "latest",
|
|
17
|
+
"react": "latest",
|
|
18
|
+
"react-native": "latest",
|
|
19
|
+
"react-native-gesture-handler": "^2.20.2",
|
|
20
|
+
"react-native-safe-area-context": "^4.10.8",
|
|
21
|
+
"react-native-screens": "^3.34.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/react": "~19.1.17",
|
|
25
|
+
"typescript": "~5.9.3"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:8787";
|
|
2
|
+
|
|
3
|
+
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
|
4
|
+
const res = await fetch(`${API_BASE_URL}${path}`, {
|
|
5
|
+
method: "POST",
|
|
6
|
+
headers: { "Content-Type": "application/json" },
|
|
7
|
+
body: JSON.stringify(body)
|
|
8
|
+
});
|
|
9
|
+
if (!res.ok) {
|
|
10
|
+
throw new Error(`API error: ${res.status}`);
|
|
11
|
+
}
|
|
12
|
+
return res.json() as Promise<T>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function apiGet<T>(path: string): Promise<T> {
|
|
16
|
+
const res = await fetch(`${API_BASE_URL}${path}`);
|
|
17
|
+
if (!res.ok) {
|
|
18
|
+
throw new Error(`API error: ${res.status}`);
|
|
19
|
+
}
|
|
20
|
+
return res.json() as Promise<T>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { COLORS, RADIUS, SPACING } from "../theme";
|
|
4
|
+
|
|
5
|
+
export function Card({ children }: { children: React.ReactNode }) {
|
|
6
|
+
return <View style={styles.card}>{children}</View>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const styles = StyleSheet.create({
|
|
10
|
+
card: {
|
|
11
|
+
backgroundColor: COLORS.surface,
|
|
12
|
+
borderRadius: RADIUS.lg,
|
|
13
|
+
padding: SPACING.md,
|
|
14
|
+
borderWidth: 1,
|
|
15
|
+
borderColor: COLORS.border
|
|
16
|
+
}
|
|
17
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Pressable, Text, StyleSheet } from "react-native";
|
|
3
|
+
import { COLORS, RADIUS, SPACING, TYPO } from "../theme";
|
|
4
|
+
|
|
5
|
+
export function Chip({
|
|
6
|
+
label,
|
|
7
|
+
active,
|
|
8
|
+
onPress
|
|
9
|
+
}: {
|
|
10
|
+
label: string;
|
|
11
|
+
active?: boolean;
|
|
12
|
+
onPress?: () => void;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<Pressable style={[styles.chip, active && styles.active]} onPress={onPress}>
|
|
16
|
+
<Text style={[styles.text, active && styles.textActive]}>{label}</Text>
|
|
17
|
+
</Pressable>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const styles = StyleSheet.create({
|
|
22
|
+
chip: {
|
|
23
|
+
paddingVertical: SPACING.sm,
|
|
24
|
+
paddingHorizontal: SPACING.md,
|
|
25
|
+
borderRadius: RADIUS.pill,
|
|
26
|
+
borderWidth: 1,
|
|
27
|
+
borderColor: COLORS.border,
|
|
28
|
+
backgroundColor: COLORS.surface
|
|
29
|
+
},
|
|
30
|
+
active: {
|
|
31
|
+
borderColor: COLORS.primary,
|
|
32
|
+
backgroundColor: COLORS.primary + "20" // 12% opacity
|
|
33
|
+
},
|
|
34
|
+
text: {
|
|
35
|
+
color: COLORS.muted,
|
|
36
|
+
fontSize: TYPO.small,
|
|
37
|
+
fontWeight: "600"
|
|
38
|
+
},
|
|
39
|
+
textActive: {
|
|
40
|
+
color: COLORS.primary
|
|
41
|
+
}
|
|
42
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, Text, StyleSheet } from "react-native";
|
|
3
|
+
import { COLORS, SPACING, TYPO } from "../theme";
|
|
4
|
+
|
|
5
|
+
export function ListRow({
|
|
6
|
+
title,
|
|
7
|
+
meta,
|
|
8
|
+
hint
|
|
9
|
+
}: {
|
|
10
|
+
title: string;
|
|
11
|
+
meta?: string;
|
|
12
|
+
hint?: string;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<View style={styles.row}>
|
|
16
|
+
<Text style={styles.title}>{title}</Text>
|
|
17
|
+
{meta ? <Text style={styles.meta}>{meta}</Text> : null}
|
|
18
|
+
{hint ? <Text style={styles.hint}>{hint}</Text> : null}
|
|
19
|
+
</View>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const styles = StyleSheet.create({
|
|
24
|
+
row: {
|
|
25
|
+
gap: SPACING.xs
|
|
26
|
+
},
|
|
27
|
+
title: {
|
|
28
|
+
color: COLORS.text,
|
|
29
|
+
fontSize: TYPO.body,
|
|
30
|
+
fontWeight: "600"
|
|
31
|
+
},
|
|
32
|
+
meta: {
|
|
33
|
+
color: COLORS.muted,
|
|
34
|
+
fontSize: TYPO.small
|
|
35
|
+
},
|
|
36
|
+
hint: {
|
|
37
|
+
color: COLORS.cta,
|
|
38
|
+
fontSize: TYPO.small,
|
|
39
|
+
fontWeight: "600"
|
|
40
|
+
}
|
|
41
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Pressable, Text, StyleSheet, ActivityIndicator } from "react-native";
|
|
3
|
+
import { COLORS, RADIUS, SPACING, TYPO } from "../theme";
|
|
4
|
+
|
|
5
|
+
export function PrimaryButton({
|
|
6
|
+
label,
|
|
7
|
+
onPress,
|
|
8
|
+
loading
|
|
9
|
+
}: {
|
|
10
|
+
label: string;
|
|
11
|
+
onPress?: () => void;
|
|
12
|
+
loading?: boolean;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<Pressable
|
|
16
|
+
style={[styles.button, loading && styles.disabled]}
|
|
17
|
+
onPress={loading ? undefined : onPress}
|
|
18
|
+
>
|
|
19
|
+
{loading ? (
|
|
20
|
+
<ActivityIndicator color={COLORS.white} />
|
|
21
|
+
) : (
|
|
22
|
+
<Text style={styles.text}>{label}</Text>
|
|
23
|
+
)}
|
|
24
|
+
</Pressable>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const styles = StyleSheet.create({
|
|
29
|
+
button: {
|
|
30
|
+
height: 52,
|
|
31
|
+
borderRadius: RADIUS.md,
|
|
32
|
+
backgroundColor: COLORS.primary,
|
|
33
|
+
alignItems: "center",
|
|
34
|
+
justifyContent: "center",
|
|
35
|
+
paddingHorizontal: SPACING.lg,
|
|
36
|
+
shadowColor: COLORS.primary,
|
|
37
|
+
shadowOffset: { width: 0, height: 4 },
|
|
38
|
+
shadowOpacity: 0.3,
|
|
39
|
+
shadowRadius: 8,
|
|
40
|
+
elevation: 4
|
|
41
|
+
},
|
|
42
|
+
text: {
|
|
43
|
+
color: COLORS.white,
|
|
44
|
+
fontWeight: "700",
|
|
45
|
+
fontSize: TYPO.body,
|
|
46
|
+
letterSpacing: 0.5
|
|
47
|
+
},
|
|
48
|
+
disabled: {
|
|
49
|
+
opacity: 0.7,
|
|
50
|
+
backgroundColor: COLORS.surfaceLight
|
|
51
|
+
}
|
|
52
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { COLORS } from "../theme";
|
|
4
|
+
|
|
5
|
+
export function ProgressBar({ value }: { value: number }) {
|
|
6
|
+
const width = `${Math.max(0, Math.min(100, value))}%`;
|
|
7
|
+
return (
|
|
8
|
+
<View style={styles.track}>
|
|
9
|
+
<View style={[styles.fill, { width }]} />
|
|
10
|
+
</View>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const styles = StyleSheet.create({
|
|
15
|
+
track: {
|
|
16
|
+
height: 10,
|
|
17
|
+
borderRadius: 6,
|
|
18
|
+
backgroundColor: "#F3DDE1",
|
|
19
|
+
overflow: "hidden"
|
|
20
|
+
},
|
|
21
|
+
fill: {
|
|
22
|
+
height: 10,
|
|
23
|
+
borderRadius: 6,
|
|
24
|
+
backgroundColor: COLORS.cta
|
|
25
|
+
}
|
|
26
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Pressable, Text, StyleSheet } from "react-native";
|
|
3
|
+
import { COLORS, RADIUS, SPACING, TYPO } from "../theme";
|
|
4
|
+
|
|
5
|
+
export function SecondaryButton({
|
|
6
|
+
label,
|
|
7
|
+
onPress
|
|
8
|
+
}: {
|
|
9
|
+
label: string;
|
|
10
|
+
onPress?: () => void;
|
|
11
|
+
}) {
|
|
12
|
+
return (
|
|
13
|
+
<Pressable style={styles.button} onPress={onPress}>
|
|
14
|
+
<Text style={styles.text}>{label}</Text>
|
|
15
|
+
</Pressable>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const styles = StyleSheet.create({
|
|
20
|
+
button: {
|
|
21
|
+
height: 48,
|
|
22
|
+
borderRadius: RADIUS.md,
|
|
23
|
+
borderWidth: 1.5,
|
|
24
|
+
borderColor: COLORS.surfaceLight,
|
|
25
|
+
backgroundColor: "transparent",
|
|
26
|
+
alignItems: "center",
|
|
27
|
+
justifyContent: "center",
|
|
28
|
+
paddingHorizontal: SPACING.lg
|
|
29
|
+
},
|
|
30
|
+
text: {
|
|
31
|
+
color: COLORS.text,
|
|
32
|
+
fontSize: TYPO.body,
|
|
33
|
+
fontWeight: "600",
|
|
34
|
+
letterSpacing: 0.3
|
|
35
|
+
}
|
|
36
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, Text, StyleSheet } from "react-native";
|
|
3
|
+
import { COLORS, SPACING, TYPO } from "../theme";
|
|
4
|
+
|
|
5
|
+
export function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<View style={styles.section}>
|
|
8
|
+
<Text style={styles.title}>{title}</Text>
|
|
9
|
+
{children}
|
|
10
|
+
</View>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const styles = StyleSheet.create({
|
|
15
|
+
section: {
|
|
16
|
+
gap: SPACING.sm
|
|
17
|
+
},
|
|
18
|
+
title: {
|
|
19
|
+
fontSize: TYPO.h3,
|
|
20
|
+
fontWeight: "600",
|
|
21
|
+
color: COLORS.text
|
|
22
|
+
}
|
|
23
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Pressable, View, StyleSheet } from "react-native";
|
|
3
|
+
import { COLORS } from "../theme";
|
|
4
|
+
|
|
5
|
+
export function Toggle({ value, onChange }: { value: boolean; onChange?: (v: boolean) => void }) {
|
|
6
|
+
return (
|
|
7
|
+
<Pressable
|
|
8
|
+
onPress={() => onChange?.(!value)}
|
|
9
|
+
style={[styles.track, value && styles.trackOn]}
|
|
10
|
+
>
|
|
11
|
+
<View style={[styles.handle, value && styles.handleOn]} />
|
|
12
|
+
</Pressable>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const styles = StyleSheet.create({
|
|
17
|
+
track: {
|
|
18
|
+
width: 52,
|
|
19
|
+
height: 28,
|
|
20
|
+
borderRadius: 16,
|
|
21
|
+
backgroundColor: "#E5E7EB",
|
|
22
|
+
padding: 3
|
|
23
|
+
},
|
|
24
|
+
trackOn: {
|
|
25
|
+
backgroundColor: "#DBEAFE"
|
|
26
|
+
},
|
|
27
|
+
handle: {
|
|
28
|
+
width: 22,
|
|
29
|
+
height: 22,
|
|
30
|
+
borderRadius: 11,
|
|
31
|
+
backgroundColor: COLORS.white
|
|
32
|
+
},
|
|
33
|
+
handleOn: {
|
|
34
|
+
alignSelf: "flex-end",
|
|
35
|
+
backgroundColor: COLORS.cta
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const MOCK_RESULTS = [
|
|
2
|
+
{ id: "1", name: "Channel Alpha", subs: "1.2M", desc: "Tutorials and deep dives" },
|
|
3
|
+
{ id: "2", name: "Channel Beta", subs: "780K", desc: "Weekly breakdowns" },
|
|
4
|
+
{ id: "3", name: "Channel Gamma", subs: "215K", desc: "Builds and experiments" }
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
export const MOCK_ACTIVE = [
|
|
8
|
+
{ id: "a1", title: "Video One", progress: 62, speed: "3.4 MB/s", eta: "2m" },
|
|
9
|
+
{ id: "a2", title: "Video Two", progress: 31, speed: "2.1 MB/s", eta: "6m" }
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export const MOCK_HISTORY = [
|
|
13
|
+
{ id: "h1", name: "Channel Alpha", subs: "1.2M" },
|
|
14
|
+
{ id: "h2", name: "Channel Gamma", subs: "215K" }
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export const MOCK_FAVORITES = [
|
|
18
|
+
{ id: "f1", name: "Channel Beta", subs: "780K" }
|
|
19
|
+
];
|