Compare commits

2 Commits
Author SHA1 Message Date
ben dbd7754dae Add Tauri CLI, app icons, and packaging config.
Wire up local build scripts and lockfiles so the desktop app can be developed and bundled consistently.
2026-08-12 13:00:30 +02:00
ben 9ae4ca1b2b Scaffold Tauri app with local-first recording UI.
Add React frontend and Rust backend stubs for MP3 import, metadata, SQLite storage, and transcription hooks.
2026-08-12 12:18:15 +02:00
74 changed files with 8002 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.DS_Store
target/
src-tauri/target/
*.tsbuildinfo
dist-packages/
app-icon.png
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>mp3Transriber</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2127
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "mp3transriber",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.0.0",
"vite": "^7.0.0"
}
}
+4776
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "mp3transriber"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
uuid = { version = "1", features = ["v4", "serde"] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability set for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-execute",
"shell:allow-open"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

+164
View File
@@ -0,0 +1,164 @@
use std::path::Path;
use rusqlite::{params, Connection};
use crate::models::{Recording, RecordingMetadata, TranscriptRecord};
pub struct Database {
connection: Connection,
}
impl Database {
pub fn open(path: &Path) -> Result<Self, String> {
let connection = Connection::open(path).map_err(|err| err.to_string())?;
let database = Self { connection };
database.migrate()?;
Ok(database)
}
fn migrate(&self) -> Result<(), String> {
self.connection
.execute_batch(
"
CREATE TABLE IF NOT EXISTS recordings (
id TEXT PRIMARY KEY,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL UNIQUE,
imported_at TEXT NOT NULL,
title TEXT,
artist TEXT,
album TEXT,
date TEXT,
duration_seconds INTEGER,
bitrate_kbps INTEGER,
transcript_text TEXT,
transcript_source TEXT,
transcript_status TEXT
);
",
)
.map_err(|err| err.to_string())
}
pub fn list_recordings(&self) -> Result<Vec<Recording>, String> {
let mut statement = self
.connection
.prepare(
"
SELECT
id,
file_name,
file_path,
imported_at,
title,
artist,
album,
date,
duration_seconds,
bitrate_kbps,
transcript_text,
transcript_source,
transcript_status
FROM recordings
ORDER BY imported_at DESC
",
)
.map_err(|err| err.to_string())?;
let rows = statement
.query_map([], |row| {
let transcript_text: Option<String> = row.get(10)?;
let transcript_source: Option<String> = row.get(11)?;
let transcript_status: Option<String> = row.get(12)?;
Ok(Recording {
id: row.get(0)?,
file_name: row.get(1)?,
file_path: row.get(2)?,
imported_at: row.get(3)?,
metadata: RecordingMetadata {
title: row.get(4)?,
artist: row.get(5)?,
album: row.get(6)?,
date: row.get(7)?,
duration_seconds: row.get(8)?,
bitrate_kbps: row.get(9)?,
},
transcript: transcript_text.map(|text| TranscriptRecord {
text,
source: transcript_source.unwrap_or_else(|| "local".to_string()),
status: transcript_status.unwrap_or_else(|| "ready".to_string()),
}),
})
})
.map_err(|err| err.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|err| err.to_string())
}
pub fn create_recording(
&self,
id: &str,
file_name: &str,
file_path: &str,
imported_at: &str,
metadata: &RecordingMetadata,
) -> Result<(), String> {
self.connection
.execute(
"
INSERT INTO recordings (
id,
file_name,
file_path,
imported_at,
title,
artist,
album,
date,
duration_seconds,
bitrate_kbps,
transcript_status
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(file_path) DO UPDATE SET
title = excluded.title,
artist = excluded.artist,
album = excluded.album,
date = excluded.date,
duration_seconds = excluded.duration_seconds,
bitrate_kbps = excluded.bitrate_kbps
",
params![
id,
file_name,
file_path,
imported_at,
metadata.title,
metadata.artist,
metadata.album,
metadata.date,
metadata.duration_seconds,
metadata.bitrate_kbps,
"pending"
],
)
.map_err(|err| err.to_string())?;
Ok(())
}
pub fn save_transcript(&self, recording_id: &str, text: &str, source: &str) -> Result<(), String> {
self.connection
.execute(
"
UPDATE recordings
SET transcript_text = ?1, transcript_source = ?2, transcript_status = ?3
WHERE id = ?4
",
params![text, source, "ready", recording_id],
)
.map_err(|err| err.to_string())?;
Ok(())
}
}
+107
View File
@@ -0,0 +1,107 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod db;
mod metadata;
mod models;
use std::path::PathBuf;
use std::sync::Mutex;
use db::Database;
use models::Recording;
use tauri::Manager;
use uuid::Uuid;
struct AppState {
db: Mutex<Database>,
}
#[tauri::command]
fn list_recordings(state: tauri::State<'_, AppState>) -> Result<Vec<Recording>, String> {
state
.db
.lock()
.map_err(|_| "Failed to lock database state".to_string())?
.list_recordings()
}
#[tauri::command]
fn import_mp3(file_path: String, state: tauri::State<'_, AppState>) -> Result<Recording, String> {
let path = PathBuf::from(&file_path);
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| "Invalid file path".to_string())?
.to_string();
let metadata = metadata::read_metadata(&file_path);
let id = Uuid::new_v4().to_string();
let imported_at = format!("{:?}", std::time::SystemTime::now());
{
let database = state
.db
.lock()
.map_err(|_| "Failed to lock database state".to_string())?;
database.create_recording(&id, &file_name, &file_path, &imported_at, &metadata)?;
}
Ok(Recording {
id,
file_name,
file_path,
imported_at,
metadata,
transcript: None,
})
}
#[tauri::command]
fn transcribe_recording(
recording_id: String,
state: tauri::State<'_, AppState>,
) -> Result<Recording, String> {
let transcript_text = "Local transcription pipeline not wired yet. This is the first scaffolded backend placeholder.".to_string();
{
let database = state
.db
.lock()
.map_err(|_| "Failed to lock database state".to_string())?;
database.save_transcript(&recording_id, &transcript_text, "local")?;
let recordings = database.list_recordings()?;
if let Some(recording) = recordings.into_iter().find(|item| item.id == recording_id) {
return Ok(recording);
}
}
Err("Recording not found".to_string())
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.setup(|app| {
let app_data_dir = app
.path()
.app_data_dir()
.map_err(|err| -> Box<dyn std::error::Error> { Box::new(err) })?;
std::fs::create_dir_all(&app_data_dir)?;
let db_path = app_data_dir.join("mp3transriber.sqlite3");
let database = Database::open(&db_path).map_err(std::io::Error::other)?;
app.manage(AppState {
db: Mutex::new(database),
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
list_recordings,
import_mp3,
transcribe_recording
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+89
View File
@@ -0,0 +1,89 @@
use std::path::Path;
use std::process::Command;
use crate::models::RecordingMetadata;
pub fn read_metadata(file_path: &str) -> RecordingMetadata {
let fallback_title = Path::new(file_path)
.file_stem()
.and_then(|stem| stem.to_str())
.map(|value| value.to_string());
let output = Command::new("ffprobe")
.args([
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
"-show_streams",
file_path,
])
.output();
let Ok(output) = output else {
return RecordingMetadata {
title: fallback_title,
artist: None,
album: None,
date: None,
duration_seconds: None,
bitrate_kbps: None,
};
};
let json: serde_json::Value = match serde_json::from_slice(&output.stdout) {
Ok(value) => value,
Err(_) => {
return RecordingMetadata {
title: fallback_title,
artist: None,
album: None,
date: None,
duration_seconds: None,
bitrate_kbps: None,
}
}
};
let tags = json
.get("format")
.and_then(|format| format.get("tags"))
.and_then(|tags| tags.as_object());
let duration_seconds = json
.get("format")
.and_then(|format| format.get("duration"))
.and_then(|value| value.as_str())
.and_then(|value| value.parse::<f64>().ok())
.map(|value| value.round() as u64);
let bitrate_kbps = json
.get("format")
.and_then(|format| format.get("bit_rate"))
.and_then(|value| value.as_str())
.and_then(|value| value.parse::<u64>().ok())
.map(|value| (value / 1000) as u32);
RecordingMetadata {
title: tags
.and_then(|items| items.get("title"))
.and_then(|value| value.as_str())
.map(|value| value.to_string())
.or(fallback_title),
artist: tags
.and_then(|items| items.get("artist"))
.and_then(|value| value.as_str())
.map(|value| value.to_string()),
album: tags
.and_then(|items| items.get("album"))
.and_then(|value| value.as_str())
.map(|value| value.to_string()),
date: tags
.and_then(|items| items.get("date"))
.and_then(|value| value.as_str())
.map(|value| value.to_string()),
duration_seconds,
bitrate_kbps,
}
}
+31
View File
@@ -0,0 +1,31 @@
use serde::Serialize;
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RecordingMetadata {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub date: Option<String>,
pub duration_seconds: Option<u64>,
pub bitrate_kbps: Option<u32>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TranscriptRecord {
pub text: String,
pub source: String,
pub status: String,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Recording {
pub id: String,
pub file_name: String,
pub file_path: String,
pub imported_at: String,
pub metadata: RecordingMetadata,
pub transcript: Option<TranscriptRecord>,
}
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "mp3Transriber",
"version": "0.1.0",
"identifier": "com.ben.mp3transriber",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist",
"devUrl": "http://localhost:5173"
},
"app": {
"windows": [
{
"title": "mp3Transriber",
"width": 1320,
"height": 860,
"resizable": true
}
],
"security": {
"csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+201
View File
@@ -0,0 +1,201 @@
import { useEffect, useMemo, useState } from "react";
import { importMp3, listRecordings, transcribeRecording } from "./lib/desktop";
import type { Recording } from "./types";
function formatDuration(seconds?: number): string {
if (seconds === undefined) {
return "Unknown";
}
const minutes = Math.floor(seconds / 60);
const remaining = seconds % 60;
return `${minutes}:${remaining.toString().padStart(2, "0")}`;
}
export default function App() {
const [recordings, setRecordings] = useState<Recording[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [manualPath, setManualPath] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void refreshRecordings();
}, []);
const selectedRecording = useMemo(
() => recordings.find((recording) => recording.id === selectedId) ?? recordings[0] ?? null,
[recordings, selectedId]
);
async function refreshRecordings() {
try {
setError(null);
const data = await listRecordings();
setRecordings(data);
if (!selectedId && data.length > 0) {
setSelectedId(data[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load recordings.");
}
}
async function handleImport() {
if (!manualPath.trim()) {
setError("Enter an MP3 file path to import.");
return;
}
try {
setBusy("import");
setError(null);
const recording = await importMp3(manualPath.trim());
setRecordings((current) => [recording, ...current]);
setSelectedId(recording.id);
setManualPath("");
} catch (err) {
setError(err instanceof Error ? err.message : "Import failed.");
} finally {
setBusy(null);
}
}
async function handleTranscribe() {
if (!selectedRecording) {
return;
}
try {
setBusy("transcribe");
setError(null);
const updated = await transcribeRecording(selectedRecording.id);
setRecordings((current) =>
current.map((recording) => (recording.id === updated.id ? updated : recording))
);
} catch (err) {
setError(err instanceof Error ? err.message : "Transcription failed.");
} finally {
setBusy(null);
}
}
return (
<div className="app-shell">
<header className="hero">
<div>
<p className="eyebrow">Local-first desktop app</p>
<h1>mp3Transriber</h1>
<p className="hero-copy">
Manage recorder MP3 files, inspect metadata, and save transcripts locally by default.
</p>
</div>
<div className="privacy-card">
<strong>Privacy default</strong>
<p>Audio and transcript text stay on-device unless a future cloud provider is explicitly enabled.</p>
</div>
</header>
<section className="import-bar">
<input
aria-label="MP3 file path"
placeholder="/path/to/recording.mp3"
value={manualPath}
onChange={(event) => setManualPath(event.target.value)}
/>
<button onClick={handleImport} disabled={busy === "import"}>
{busy === "import" ? "Importing..." : "Import MP3"}
</button>
<button className="secondary" onClick={() => void refreshRecordings()}>
Refresh
</button>
</section>
{error ? <p className="error-banner">{error}</p> : null}
<main className="content-grid">
<section className="panel">
<div className="panel-header">
<h2>Recordings</h2>
<span>{recordings.length}</span>
</div>
<div className="recordings-list">
{recordings.map((recording) => (
<button
key={recording.id}
className={recording.id === selectedRecording?.id ? "recording-item active" : "recording-item"}
onClick={() => setSelectedId(recording.id)}
>
<strong>{recording.fileName}</strong>
<span>{new Date(recording.importedAt).toLocaleString()}</span>
</button>
))}
{recordings.length === 0 ? <p className="empty-state">No recordings imported yet.</p> : null}
</div>
</section>
<section className="panel">
<div className="panel-header">
<h2>Metadata</h2>
<button onClick={handleTranscribe} disabled={!selectedRecording || busy === "transcribe"}>
{busy === "transcribe" ? "Transcribing..." : "Transcribe"}
</button>
</div>
{selectedRecording ? (
<dl className="metadata-grid">
<div>
<dt>Path</dt>
<dd>{selectedRecording.filePath}</dd>
</div>
<div>
<dt>Title</dt>
<dd>{selectedRecording.metadata.title ?? "Unknown"}</dd>
</div>
<div>
<dt>Artist</dt>
<dd>{selectedRecording.metadata.artist ?? "Unknown"}</dd>
</div>
<div>
<dt>Album</dt>
<dd>{selectedRecording.metadata.album ?? "Unknown"}</dd>
</div>
<div>
<dt>Date</dt>
<dd>{selectedRecording.metadata.date ?? "Unknown"}</dd>
</div>
<div>
<dt>Duration</dt>
<dd>{formatDuration(selectedRecording.metadata.durationSeconds)}</dd>
</div>
<div>
<dt>Bitrate</dt>
<dd>
{selectedRecording.metadata.bitrateKbps !== undefined
? `${selectedRecording.metadata.bitrateKbps} kbps`
: "Unknown"}
</dd>
</div>
</dl>
) : (
<p className="empty-state">Select a recording to inspect its metadata.</p>
)}
</section>
<section className="panel transcript-panel">
<div className="panel-header">
<h2>Transcript</h2>
<span>{selectedRecording?.transcript?.source ?? "none"}</span>
</div>
{selectedRecording?.transcript ? (
<>
<p className="status-pill">{selectedRecording.transcript.status}</p>
<pre>{selectedRecording.transcript.text}</pre>
</>
) : (
<p className="empty-state">No transcript saved for this recording yet.</p>
)}
</section>
</main>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { invoke } from "@tauri-apps/api/core";
import type { Recording } from "../types";
const mockRecordings: Recording[] = [
{
id: "demo-1",
fileName: "meeting-note.mp3",
filePath: "/recordings/meeting-note.mp3",
importedAt: new Date().toISOString(),
metadata: {
title: "Meeting Note",
artist: "Pocket Recorder",
album: "Field Notes",
durationSeconds: 74,
bitrateKbps: 192
},
transcript: {
text: "This is sample placeholder transcript text for the initial scaffold.",
source: "local",
status: "ready"
}
}
];
function isTauriRuntime(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
export async function listRecordings(): Promise<Recording[]> {
if (!isTauriRuntime()) {
return mockRecordings;
}
return invoke<Recording[]>("list_recordings");
}
export async function importMp3(filePath: string): Promise<Recording> {
if (!isTauriRuntime()) {
return {
id: crypto.randomUUID(),
fileName: filePath.split("/").pop() ?? "recording.mp3",
filePath,
importedAt: new Date().toISOString(),
metadata: {},
transcript: {
text: "Local import mock created in browser mode.",
source: "local",
status: "pending"
}
};
}
return invoke<Recording>("import_mp3", { filePath });
}
export async function transcribeRecording(recordingId: string): Promise<Recording> {
if (!isTauriRuntime()) {
const recording = mockRecordings.find((item) => item.id === recordingId);
if (!recording) {
throw new Error("Recording not found");
}
return {
...recording,
transcript: {
text: "Mock local transcription completed in browser mode.",
source: "local",
status: "ready"
}
};
}
return invoke<Recording>("transcribe_recording", { recordingId });
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+226
View File
@@ -0,0 +1,226 @@
:root {
color-scheme: dark;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f172a;
color: #e2e8f0;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: linear-gradient(180deg, #0f172a 0%, #111827 100%);
}
button,
input {
font: inherit;
}
button {
border: 0;
border-radius: 10px;
padding: 0.75rem 1rem;
background: #2563eb;
color: white;
cursor: pointer;
}
button.secondary {
background: #334155;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
input {
width: 100%;
border: 1px solid #334155;
border-radius: 10px;
background: #0f172a;
color: #e2e8f0;
padding: 0.75rem 1rem;
}
#root {
min-height: 100vh;
}
.app-shell {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.hero {
display: flex;
justify-content: space-between;
gap: 1.5rem;
align-items: start;
margin-bottom: 1.5rem;
}
.eyebrow {
margin: 0 0 0.5rem;
color: #93c5fd;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.8rem;
}
.hero h1 {
margin: 0;
font-size: 2.5rem;
}
.hero-copy {
max-width: 48rem;
color: #cbd5e1;
}
.privacy-card,
.panel {
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(148, 163, 184, 0.15);
border-radius: 16px;
box-shadow: 0 20px 50px rgba(2, 6, 23, 0.25);
}
.privacy-card {
max-width: 22rem;
padding: 1rem 1.25rem;
}
.privacy-card p {
color: #cbd5e1;
margin-bottom: 0;
}
.import-bar {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.75rem;
margin-bottom: 1rem;
}
.error-banner {
padding: 0.85rem 1rem;
border-radius: 12px;
background: rgba(127, 29, 29, 0.65);
color: #fecaca;
}
.content-grid {
display: grid;
grid-template-columns: minmax(260px, 0.85fr) minmax(320px, 1fr) minmax(320px, 1.1fr);
gap: 1rem;
}
.panel {
padding: 1rem;
min-height: 420px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.panel-header h2 {
margin: 0;
font-size: 1.1rem;
}
.recordings-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.recording-item {
display: flex;
flex-direction: column;
align-items: start;
gap: 0.25rem;
text-align: left;
background: #1e293b;
}
.recording-item.active {
outline: 1px solid #60a5fa;
background: #172554;
}
.recording-item span,
.empty-state,
dd {
color: #cbd5e1;
}
.metadata-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.9rem;
margin: 0;
}
dt {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #93c5fd;
margin-bottom: 0.25rem;
}
dd {
margin: 0;
word-break: break-word;
}
.transcript-panel pre {
margin: 0;
white-space: pre-wrap;
font-family: "JetBrains Mono", "Fira Code", monospace;
line-height: 1.6;
color: #e2e8f0;
}
.status-pill {
display: inline-block;
margin: 0 0 0.75rem;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: #1d4ed8;
font-size: 0.85rem;
}
@media (max-width: 1024px) {
.hero,
.content-grid {
grid-template-columns: 1fr;
display: grid;
}
.privacy-card {
max-width: none;
}
}
@media (max-width: 720px) {
.app-shell {
padding: 1rem;
}
.import-bar {
grid-template-columns: 1fr;
}
}
+25
View File
@@ -0,0 +1,25 @@
export type TranscriptSource = "local" | "cloud";
export interface RecordingMetadata {
title?: string;
artist?: string;
album?: string;
date?: string;
durationSeconds?: number;
bitrateKbps?: number;
}
export interface TranscriptRecord {
text: string;
source: TranscriptSource;
status: "ready" | "pending";
}
export interface Recording {
id: string;
fileName: string;
filePath: string;
importedAt: string;
metadata: RecordingMetadata;
transcript?: TranscriptRecord;
}
+10
View File
@@ -0,0 +1,10 @@
create an app that allows a user to manage recordings from a pocket recorder device.
Allow user to see the meta data in the mp3's .
add a feture that can transcribe the mp3 to text and also save the transcibed text in the app.
use an sqllite3 database.
the app must be able to run on linux or a mac computer and easy to start when downloaded / aquired.
discuss a dev stack for this project
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
}
]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
clearScreen: false
});