Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbd7754dae | ||
|
|
9ae4ca1b2b |
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
target/
|
||||||
|
src-tauri/target/
|
||||||
|
*.tsbuildinfo
|
||||||
|
dist-packages/
|
||||||
|
app-icon.png
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 299 B |
|
After Width: | Height: | Size: 665 B |
|
After Width: | Height: | Size: 104 B |
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 239 B |
|
After Width: | Height: | Size: 328 B |
|
After Width: | Height: | Size: 347 B |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 104 B |
|
After Width: | Height: | Size: 852 B |
|
After Width: | Height: | Size: 121 B |
|
After Width: | Height: | Size: 172 B |
|
After Width: | Height: | Size: 200 B |
|
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>
|
||||||
|
After Width: | Height: | Size: 177 B |
|
After Width: | Height: | Size: 374 B |
|
After Width: | Height: | Size: 281 B |
|
After Width: | Height: | Size: 176 B |
|
After Width: | Height: | Size: 240 B |
|
After Width: | Height: | Size: 278 B |
|
After Width: | Height: | Size: 285 B |
|
After Width: | Height: | Size: 515 B |
|
After Width: | Height: | Size: 488 B |
|
After Width: | Height: | Size: 429 B |
|
After Width: | Height: | Size: 902 B |
|
After Width: | Height: | Size: 717 B |
|
After Width: | Height: | Size: 567 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 91 B |
|
After Width: | Height: | Size: 116 B |
|
After Width: | Height: | Size: 116 B |
|
After Width: | Height: | Size: 146 B |
|
After Width: | Height: | Size: 104 B |
|
After Width: | Height: | Size: 143 B |
|
After Width: | Height: | Size: 143 B |
|
After Width: | Height: | Size: 193 B |
|
After Width: | Height: | Size: 116 B |
|
After Width: | Height: | Size: 185 B |
|
After Width: | Height: | Size: 185 B |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 179 B |
|
After Width: | Height: | Size: 352 B |
|
After Width: | Height: | Size: 388 B |
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
clearScreen: false
|
||||||
|
});
|
||||||