Refactor project name from "Voice KB" to "Faerro KB" across all files and update related configurations
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# Voice KB project notes
|
# Faerro KB project notes
|
||||||
|
|
||||||
- Keep audio, transcripts, and indexes on infrastructure controlled by the user.
|
- Keep audio, transcripts, and indexes on infrastructure controlled by the user.
|
||||||
- Never add a cloud speech, LLM, or geocoding dependency.
|
- Never add a cloud speech, LLM, or geocoding dependency.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /srv/voice-kb
|
WORKDIR /srv/faerro-kb
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Voice KB
|
# Faerro KB
|
||||||
|
|
||||||
A self-hosted voice-note inbox for an iPhone-installed web app. Recordings are saved in the browser first, uploaded when connectivity is available, and stored with transcripts on your own server.
|
A self-hosted voice-note inbox for an iPhone-installed web app. Recordings are saved in the browser first, uploaded when connectivity is available, and stored with transcripts on your own server.
|
||||||
|
|
||||||
|
|||||||
+38
-9
@@ -12,14 +12,14 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
|
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
|
||||||
AUDIO_DIR = DATA_DIR / "audio"
|
AUDIO_DIR = DATA_DIR / "audio"
|
||||||
DB_PATH = DATA_DIR / "voice-kb.sqlite3"
|
DB_PATH = DATA_DIR / "faerro-kb.sqlite3"
|
||||||
WHISPER_SERVER_URL = os.environ.get("WHISPER_SERVER_URL", "").strip()
|
WHISPER_SERVER_URL = os.environ.get("WHISPER_SERVER_URL", "").strip()
|
||||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||||
|
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
app = FastAPI(title="Voice KB")
|
app = FastAPI(title="Faerro KB")
|
||||||
|
|
||||||
|
|
||||||
def connect_db() -> sqlite3.Connection:
|
def connect_db() -> sqlite3.Connection:
|
||||||
@@ -51,12 +51,12 @@ initialize_db()
|
|||||||
|
|
||||||
def update_transcript(note_id: str, transcript: str, status: str) -> None:
|
def update_transcript(note_id: str, transcript: str, status: str) -> None:
|
||||||
with connect_db() as connection:
|
with connect_db() as connection:
|
||||||
connection.execute(
|
result = connection.execute(
|
||||||
"UPDATE notes SET transcript = ?, status = ? WHERE id = ?",
|
"UPDATE notes SET transcript = ?, status = ? WHERE id = ?",
|
||||||
(transcript, status, note_id),
|
(transcript, status, note_id),
|
||||||
)
|
)
|
||||||
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
|
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
|
||||||
if transcript.strip():
|
if result.rowcount and transcript.strip():
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO note_search (note_id, transcript) VALUES (?, ?)",
|
"INSERT INTO note_search (note_id, transcript) VALUES (?, ?)",
|
||||||
(note_id, transcript.strip()),
|
(note_id, transcript.strip()),
|
||||||
@@ -135,16 +135,31 @@ async def create_note(
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/notes")
|
@app.get("/api/notes")
|
||||||
def list_notes(q: str = "") -> list[dict[str, str]]:
|
def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
|
||||||
with connect_db() as connection:
|
with connect_db() as connection:
|
||||||
search_terms = re.findall(r"[\w]+", q, flags=re.UNICODE)
|
search_terms = re.findall(r"[\w]+", q, flags=re.UNICODE)
|
||||||
if search_terms:
|
if search_terms:
|
||||||
query = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in search_terms)
|
query = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in search_terms)
|
||||||
|
if status:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status
|
||||||
|
FROM note_search s JOIN notes n ON n.id = s.note_id
|
||||||
|
WHERE note_search MATCH ? AND n.status = ?
|
||||||
|
ORDER BY n.created_at DESC LIMIT 100""",
|
||||||
|
(query, status),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status
|
||||||
|
FROM note_search s JOIN notes n ON n.id = s.note_id
|
||||||
|
WHERE note_search MATCH ? ORDER BY n.created_at DESC LIMIT 100""",
|
||||||
|
(query,),
|
||||||
|
).fetchall()
|
||||||
|
elif status:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status
|
"""SELECT id, created_at, filename, transcript, status
|
||||||
FROM note_search s JOIN notes n ON n.id = s.note_id
|
FROM notes WHERE status = ? ORDER BY created_at DESC LIMIT 100""",
|
||||||
WHERE note_search MATCH ? ORDER BY n.created_at DESC LIMIT 100""",
|
(status,),
|
||||||
(query,),
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
@@ -154,6 +169,20 @@ def list_notes(q: str = "") -> list[dict[str, str]]:
|
|||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/notes/{note_id}")
|
||||||
|
def delete_note(note_id: str) -> dict[str, str]:
|
||||||
|
with connect_db() as connection:
|
||||||
|
note = connection.execute(
|
||||||
|
"SELECT audio_path FROM notes WHERE id = ?", (note_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not note:
|
||||||
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
|
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
|
||||||
|
connection.execute("DELETE FROM notes WHERE id = ?", (note_id,))
|
||||||
|
Path(note["audio_path"]).unlink(missing_ok=True)
|
||||||
|
return {"id": note_id, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/notes/{note_id}/transcript")
|
@app.put("/api/notes/{note_id}/transcript")
|
||||||
def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
|
def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
|
||||||
transcript = payload.get("transcript", "").strip()
|
transcript = payload.get("transcript", "").strip()
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
voice-kb:
|
faerro-kb:
|
||||||
image: gitea.faerro.it/andrea/faerro-kb:0.0.1
|
image: gitea.faerro.it/andrea/faerro-kb:0.0.1
|
||||||
ports:
|
ports:
|
||||||
- "${HTTP_PORT:-80}:80"
|
- "${HTTP_PORT:-80}:80"
|
||||||
|
|||||||
+50
-22
@@ -1,9 +1,9 @@
|
|||||||
const DB_NAME = 'voice-kb-local';
|
const DB_NAME = 'faerro-kb-local';
|
||||||
const STORE_NAME = 'recordings';
|
const STORE_NAME = 'recordings';
|
||||||
const MAX_RECORDING_MS = 30 * 60 * 1000;
|
const MAX_RECORDING_MS = 30 * 60 * 1000;
|
||||||
const elements = Object.fromEntries([
|
const elements = Object.fromEntries([
|
||||||
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'capture-message',
|
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'capture-message',
|
||||||
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty', 'search-input',
|
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty',
|
||||||
'network-state', 'date-label', 'storage-status',
|
'network-state', 'date-label', 'storage-status',
|
||||||
].map((id) => [id, document.getElementById(id)]));
|
].map((id) => [id, document.getElementById(id)]));
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ let activeStream;
|
|||||||
let recordingChunks = [];
|
let recordingChunks = [];
|
||||||
let recordingStartedAt = 0;
|
let recordingStartedAt = 0;
|
||||||
let recordingTimer;
|
let recordingTimer;
|
||||||
let searchTimer;
|
|
||||||
let syncing = false;
|
let syncing = false;
|
||||||
|
|
||||||
function openDatabase() {
|
function openDatabase() {
|
||||||
@@ -138,6 +137,7 @@ async function syncQueue() {
|
|||||||
if (syncing || !navigator.onLine) return;
|
if (syncing || !navigator.onLine) return;
|
||||||
syncing = true;
|
syncing = true;
|
||||||
elements['sync-button'].disabled = true;
|
elements['sync-button'].disabled = true;
|
||||||
|
elements['queue-list'].querySelectorAll('.delete-note').forEach((button) => { button.disabled = true; });
|
||||||
try {
|
try {
|
||||||
const recordings = await getQueued();
|
const recordings = await getQueued();
|
||||||
for (const recording of recordings.sort((a, b) => a.createdAtMs - b.createdAtMs)) {
|
for (const recording of recordings.sort((a, b) => a.createdAtMs - b.createdAtMs)) {
|
||||||
@@ -159,7 +159,7 @@ async function syncQueue() {
|
|||||||
syncing = false;
|
syncing = false;
|
||||||
elements['sync-button'].disabled = false;
|
elements['sync-button'].disabled = false;
|
||||||
await renderQueue();
|
await renderQueue();
|
||||||
await loadArchive(elements['search-input'].value.trim());
|
await loadAwaitingNotes();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,18 +191,41 @@ async function renderQueue() {
|
|||||||
const meta = document.createElement('div');
|
const meta = document.createElement('div');
|
||||||
meta.className = 'item-meta';
|
meta.className = 'item-meta';
|
||||||
meta.textContent = `${prettyDate(recording.createdAt)} · ${formatDuration(recording.durationMs)}`;
|
meta.textContent = `${prettyDate(recording.createdAt)} · ${formatDuration(recording.durationMs)}`;
|
||||||
|
const remove = createDeleteButton(`Delete queued note ${recording.filename}`);
|
||||||
|
remove.disabled = syncing;
|
||||||
|
remove.addEventListener('click', async () => {
|
||||||
|
if (syncing) return;
|
||||||
|
remove.disabled = true;
|
||||||
|
try {
|
||||||
|
await deleteQueued(recording.id);
|
||||||
|
await renderQueue();
|
||||||
|
} catch {
|
||||||
|
setMessage('Could not remove the queued recording.', true);
|
||||||
|
remove.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
top.append(name, status);
|
top.append(name, status);
|
||||||
body.append(top, meta);
|
body.append(top, meta);
|
||||||
item.append(icon, body);
|
item.append(icon, body, remove);
|
||||||
elements['queue-list'].append(item);
|
elements['queue-list'].append(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDeleteButton(label) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'delete-note';
|
||||||
|
button.type = 'button';
|
||||||
|
button.textContent = '×';
|
||||||
|
button.title = label;
|
||||||
|
button.setAttribute('aria-label', label);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
function renderArchive(notes) {
|
function renderArchive(notes) {
|
||||||
elements['archive-list'].replaceChildren();
|
elements['archive-list'].replaceChildren();
|
||||||
elements['archive-empty'].hidden = notes.length > 0;
|
elements['archive-empty'].hidden = notes.length > 0;
|
||||||
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to browse or search stored notes.';
|
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view notes awaiting transcription.';
|
||||||
else elements['archive-empty'].textContent = elements['search-input'].value.trim() ? 'No matching transcripts.' : 'Your stored notes will appear here.';
|
else elements['archive-empty'].textContent = 'No notes awaiting transcription.';
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
const item = document.createElement('article');
|
const item = document.createElement('article');
|
||||||
item.className = 'archive-item';
|
item.className = 'archive-item';
|
||||||
@@ -230,8 +253,7 @@ function renderArchive(notes) {
|
|||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }),
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Save failed');
|
if (!response.ok) throw new Error('Save failed');
|
||||||
save.textContent = 'SAVED';
|
await loadAwaitingNotes();
|
||||||
await loadArchive(elements['search-input'].value.trim());
|
|
||||||
} catch {
|
} catch {
|
||||||
save.textContent = 'RETRY SAVE';
|
save.textContent = 'RETRY SAVE';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -240,22 +262,32 @@ function renderArchive(notes) {
|
|||||||
});
|
});
|
||||||
const status = document.createElement('div');
|
const status = document.createElement('div');
|
||||||
status.className = 'archive-status';
|
status.className = 'archive-status';
|
||||||
status.textContent = note.status.replaceAll('_', ' ').toUpperCase();
|
status.textContent = 'AWAITING TRANSCRIPTION';
|
||||||
|
const remove = createDeleteButton(`Delete note ${note.filename}`);
|
||||||
|
remove.addEventListener('click', async () => {
|
||||||
|
remove.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/notes/${encodeURIComponent(note.id)}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) throw new Error('Delete failed');
|
||||||
|
await loadAwaitingNotes();
|
||||||
|
} catch {
|
||||||
|
remove.disabled = false;
|
||||||
|
setMessage('Could not delete this note. Please try again.', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
body.append(name, transcript, save, status);
|
body.append(name, transcript, save, status);
|
||||||
item.append(date, body);
|
item.append(date, body, remove);
|
||||||
elements['archive-list'].append(item);
|
elements['archive-list'].append(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadArchive(query = '') {
|
async function loadAwaitingNotes() {
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
renderArchive([]);
|
renderArchive([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const url = new URL('/api/notes', window.location.origin);
|
const response = await fetch('/api/notes?status=awaiting_transcription');
|
||||||
if (query) url.searchParams.set('q', query);
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) throw new Error('Archive unavailable');
|
if (!response.ok) throw new Error('Archive unavailable');
|
||||||
renderArchive(await response.json());
|
renderArchive(await response.json());
|
||||||
} catch {
|
} catch {
|
||||||
@@ -267,17 +299,13 @@ async function loadArchive(query = '') {
|
|||||||
|
|
||||||
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
|
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
|
||||||
elements['sync-button'].addEventListener('click', syncQueue);
|
elements['sync-button'].addEventListener('click', syncQueue);
|
||||||
elements['search-input'].addEventListener('input', () => {
|
window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); });
|
||||||
clearTimeout(searchTimer);
|
|
||||||
searchTimer = setTimeout(() => loadArchive(elements['search-input'].value.trim()), 250);
|
|
||||||
});
|
|
||||||
window.addEventListener('online', () => { updateNetwork(); syncQueue(); loadArchive(elements['search-input'].value.trim()); });
|
|
||||||
window.addEventListener('offline', updateNetwork);
|
window.addEventListener('offline', updateNetwork);
|
||||||
window.addEventListener('focus', () => { updateNetwork(); syncQueue(); loadArchive(elements['search-input'].value.trim()); });
|
window.addEventListener('focus', () => { updateNetwork(); syncQueue(); loadAwaitingNotes(); });
|
||||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') syncQueue(); });
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') syncQueue(); });
|
||||||
|
|
||||||
elements['date-label'].textContent = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()).toUpperCase();
|
elements['date-label'].textContent = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()).toUpperCase();
|
||||||
updateNetwork();
|
updateNetwork();
|
||||||
renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true));
|
renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true));
|
||||||
loadArchive();
|
loadAwaitingNotes();
|
||||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
+5
-8
@@ -6,21 +6,19 @@
|
|||||||
<meta name="theme-color" content="#f5f3ed" />
|
<meta name="theme-color" content="#f5f3ed" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<title>Voice KB</title>
|
<title>Faerro KB</title>
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/" aria-label="Voice KB home"><span class="brand-mark">V</span><span>voice <b>kb</b></span></a>
|
<a class="brand" href="/" aria-label="Faerro KB"><span class="brand-mark">V</span><span>voice <b>kb</b></span></a>
|
||||||
<span id="network-state" class="network-state"><i></i> Checking</span>
|
<span id="network-state" class="network-state"><i></i> Checking</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section class="capture" aria-labelledby="capture-title">
|
<section class="capture" aria-labelledby="capture-title">
|
||||||
<div class="section-kicker"><span>PERSONAL ARCHIVE</span><span id="date-label"></span></div>
|
<div class="section-kicker"><span>PERSONAL ARCHIVE</span><span id="date-label"></span></div>
|
||||||
<h1 id="capture-title">Catch the thought<br /><em>before it goes.</em></h1>
|
|
||||||
<p class="intro">Private voice notes, kept on your own server.</p>
|
|
||||||
<div class="recorder-row">
|
<div class="recorder-row">
|
||||||
<button id="record-button" class="record-button" type="button" aria-label="Start recording">
|
<button id="record-button" class="record-button" type="button" aria-label="Start recording">
|
||||||
<span class="record-dot"></span>
|
<span class="record-dot"></span>
|
||||||
@@ -46,15 +44,14 @@
|
|||||||
|
|
||||||
<section class="archive-section" aria-labelledby="archive-title">
|
<section class="archive-section" aria-labelledby="archive-title">
|
||||||
<div class="section-heading archive-heading">
|
<div class="section-heading archive-heading">
|
||||||
<div><span class="section-kicker">YOUR LIBRARY</span><h2 id="archive-title">Recent notes</h2></div>
|
<div><span class="section-kicker">SERVER STORED</span><h2 id="archive-title">Awaiting transcription</h2></div>
|
||||||
<label class="search-box"><span aria-hidden="true">⌕</span><input id="search-input" type="search" placeholder="Search transcripts" autocomplete="off" /></label>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="archive-list" class="archive-list"></div>
|
<div id="archive-list" class="archive-list"></div>
|
||||||
<p id="archive-empty" class="empty-state">Your stored notes will appear here.</p>
|
<p id="archive-empty" class="empty-state">Notes awaiting transcription will appear here.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer><span>LOCAL FIRST</span><span id="storage-status">Audio stays on this device until uploaded.</span></footer>
|
<footer><p>Faerro KB - Copyright Andrea Ferronato © 2026</p></footer>
|
||||||
<script src="/app.js" defer></script>
|
<script src="/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Voice KB",
|
"name": "Faerro KB",
|
||||||
"short_name": "Voice KB",
|
"short_name": "Faerro KB",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f5f3ed",
|
"background_color": "#f5f3ed",
|
||||||
|
|||||||
+2
-4
@@ -56,12 +56,10 @@ h2 { margin: 8px 0 0; font: 500 25px/1.1 var(--serif); }
|
|||||||
.item-name { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
|
.item-name { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
|
||||||
.item-meta { margin-top: 5px; color: var(--muted); font: 10px var(--mono); }
|
.item-meta { margin-top: 5px; color: var(--muted); font: 10px var(--mono); }
|
||||||
.item-status { color: var(--moss-dark); font: 10px var(--mono); white-space: nowrap; }
|
.item-status { color: var(--moss-dark); font: 10px var(--mono); white-space: nowrap; }
|
||||||
|
.delete-note { width: 32px; height: 32px; flex: 0 0 auto; border: 1px solid var(--line); background: transparent; color: var(--coral); font: 20px/1 var(--sans); cursor: pointer; }
|
||||||
|
.delete-note:disabled { opacity: .45; cursor: wait; }
|
||||||
.empty-state { margin: 18px 0 0; color: var(--muted); font-size: 12px; }
|
.empty-state { margin: 18px 0 0; color: var(--muted); font-size: 12px; }
|
||||||
.archive-heading { align-items: end; }
|
.archive-heading { align-items: end; }
|
||||||
.search-box { width: min(280px, 52vw); height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 11px; border: 1px solid var(--line); background: #fffefa; color: var(--muted); }
|
|
||||||
.search-box span { font-size: 22px; line-height: 1; }
|
|
||||||
.search-box input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; color: var(--ink); font: 12px var(--sans); }
|
|
||||||
.search-box input::placeholder { color: #899088; }
|
|
||||||
.archive-item { padding: 16px 0; }
|
.archive-item { padding: 16px 0; }
|
||||||
.archive-date { width: 72px; flex: 0 0 auto; color: var(--muted); font: 10px/1.5 var(--mono); }
|
.archive-date { width: 72px; flex: 0 0 auto; color: var(--muted); font: 10px/1.5 var(--mono); }
|
||||||
.archive-transcript { width: 100%; min-height: 56px; resize: vertical; border: 0; border-bottom: 1px solid #e2e3dc; outline: 0; padding: 0 0 9px; background: transparent; color: var(--ink); font: 13px/1.55 var(--sans); }
|
.archive-transcript { width: 100%; min-height: 56px; resize: vertical; border: 0; border-bottom: 1px solid #e2e3dc; outline: 0; padding: 0 0 9px; background: transparent; color: var(--ink); font: 13px/1.55 var(--sans); }
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'voice-kb-shell-v1';
|
const CACHE_NAME = 'faerro-kb-shell-v1';
|
||||||
const APP_SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
|
const APP_SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user