Compare commits
3
Commits
651c6de392
...
8d4f46a7d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d4f46a7d1 | ||
|
|
f4100af8ec | ||
|
|
c883fbc47f |
@@ -19,6 +19,7 @@ Faerro KB is a self-hosted voice-note inbox designed for an iPhone-installed web
|
||||
- Preserve the local-first capture and retry behavior when changing the PWA, API, or service worker. Prefer additive, recoverable data changes.
|
||||
- Follow the existing small stack: FastAPI, SQLite/FTS5, and the vanilla JavaScript PWA. Avoid new dependencies unless they solve a real need and can run on the user's infrastructure.
|
||||
- Keep changes scoped. Inspect the owning code path, add or update focused tests for behavior changes, and run the narrowest relevant checks available.
|
||||
- If tooling required for a relevant test or check is missing, tell the user which tool is needed and prompt them to install it on the local machine; do not silently skip the check.
|
||||
- Update README/UI documentation when behavior, deployment, security, backup, or platform limitations change. Do not claim a capability that iOS or browsers cannot guarantee.
|
||||
- Preserve user data and existing local changes. Do not perform destructive migrations or remove stored recordings without explicit confirmation and a recovery path.
|
||||
- At the end of each chat, check whether it introduced durable project requirements, decisions, workflows, or corrections missing from these instructions or a relevant skill. Update the narrowest relevant guidance file when the change is clearly reusable and within the user's authorized scope; otherwise note the gap and ask before expanding scope. Do not record transient task details or sensitive information.
|
||||
|
||||
@@ -26,5 +26,6 @@ Use this skill for feature work, bug fixes, and reviews in this repository. The
|
||||
|
||||
- Add or update focused tests for changed behavior, especially queue acknowledgement, retries, and data-preserving transcript workflows.
|
||||
- Run the narrowest relevant test, compile, or lint check available, then document any important limitation or unverified check.
|
||||
- If tooling required for a relevant test or check is missing, tell the user which tool is needed and prompt them to install it locally instead of silently skipping the check.
|
||||
- Update `README.md` when setup, deployment, backups, privacy, or platform behavior changes.
|
||||
- Never claim that iOS guarantees background execution or that browser storage is permanent.
|
||||
@@ -29,6 +29,7 @@ In Safari on iPhone, open the HTTPS address and use **Share > Add to Home Screen
|
||||
## Capture and offline behavior
|
||||
|
||||
- Recording data and its timestamp are committed to IndexedDB before upload is attempted.
|
||||
- Written-note drafts and queued text notes are stored in IndexedDB on the device. A draft is restored when the app is reopened, and queued text is removed only after the server confirms storage.
|
||||
- Microphone recordings use the audio format supported by the browser (commonly M4A/AAC on iPhone Safari or Opus/WebM elsewhere). You can also add an audio file from the device; both sources remain queued locally until the server confirms storage.
|
||||
- The server keeps each uploaded original and creates a mono PCM WAV copy at 16-bit / 22.05 kHz for transcription. FFmpeg performs this conversion locally in the container. After a non-empty transcript is stored, both audio files are deleted; failed or empty transcriptions keep the audio for recovery.
|
||||
- Uploads retry when the app opens, returns to the foreground, or the browser reports a connection. A recording is removed from the phone's queue only after the server confirms it was stored.
|
||||
|
||||
+36
-5
@@ -199,6 +199,36 @@ async def create_note(
|
||||
return {"id": note_id, "status": status}
|
||||
|
||||
|
||||
@app.post("/api/text-notes", status_code=201)
|
||||
def create_text_note(payload: dict[str, str]) -> dict[str, str]:
|
||||
text = payload.get("text", "").strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="Note text is required")
|
||||
try:
|
||||
note_id = str(uuid.UUID(payload.get("id", "")))
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail="Invalid note ID") from error
|
||||
try:
|
||||
parsed_date = datetime.fromisoformat(payload.get("created_at", "").replace("Z", "+00:00"))
|
||||
if parsed_date.tzinfo is None:
|
||||
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
|
||||
created_at = parsed_date.astimezone(timezone.utc).isoformat()
|
||||
except ValueError:
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with connect_db() as connection:
|
||||
existing = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone()
|
||||
if existing:
|
||||
return {"id": note_id, "status": existing["status"]}
|
||||
connection.execute(
|
||||
"""INSERT INTO notes (id, created_at, filename, audio_path, transcript, status)
|
||||
VALUES (?, ?, ?, '', ?, 'written')""",
|
||||
(note_id, created_at, "Written note", text),
|
||||
)
|
||||
connection.execute("INSERT INTO note_search (note_id, transcript) VALUES (?, ?)", (note_id, text))
|
||||
return {"id": note_id, "status": "written"}
|
||||
|
||||
|
||||
@app.get("/api/notes")
|
||||
def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
|
||||
with connect_db() as connection:
|
||||
@@ -255,13 +285,14 @@ def delete_note(note_id: str) -> dict[str, str]:
|
||||
def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
|
||||
transcript = payload.get("transcript", "").strip()
|
||||
with connect_db() as connection:
|
||||
exists = connection.execute("SELECT 1 FROM notes WHERE id = ?", (note_id,)).fetchone()
|
||||
if not exists:
|
||||
note = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
update_transcript(note_id, transcript, "transcribed" if transcript else "awaiting_transcription")
|
||||
if transcript:
|
||||
status = "written" if note["status"] == "written" else "transcribed" if transcript else "awaiting_transcription"
|
||||
update_transcript(note_id, transcript, status)
|
||||
if transcript and note["status"] != "written":
|
||||
remove_transcribed_audio(note_id)
|
||||
return {"id": note_id, "status": "transcribed" if transcript else "awaiting_transcription"}
|
||||
return {"id": note_id, "status": status}
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
|
||||
+144
-16
@@ -1,10 +1,12 @@
|
||||
const DB_NAME = 'faerro-kb-local';
|
||||
const STORE_NAME = 'recordings';
|
||||
const TEXT_STORE_NAME = 'text-notes';
|
||||
const DRAFT_ID = 'current-draft';
|
||||
const MAX_RECORDING_MS = 30 * 60 * 1000;
|
||||
const elements = Object.fromEntries([
|
||||
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'audio-level', 'audio-level-fill', 'capture-message', 'audio-file-input',
|
||||
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty',
|
||||
'network-state', 'storage-status',
|
||||
'network-state', 'storage-status', 'voice-mode', 'write-mode', 'voice-panel', 'write-panel', 'written-note', 'draft-status', 'save-written-note',
|
||||
].map((id) => [id, document.getElementById(id)]));
|
||||
|
||||
let databasePromise;
|
||||
@@ -16,12 +18,17 @@ let recordingChunks = [];
|
||||
let recordingStartedAt = 0;
|
||||
let recordingTimer;
|
||||
let syncing = false;
|
||||
let draftSaveTimer;
|
||||
|
||||
function openDatabase() {
|
||||
if (databasePromise) return databasePromise;
|
||||
databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
const request = indexedDB.open(DB_NAME, 2);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains(TEXT_STORE_NAME)) db.createObjectStore(TEXT_STORE_NAME, { keyPath: 'id' });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
@@ -44,6 +51,92 @@ const getQueued = () => queueTransaction('readonly', (store) => store.getAll());
|
||||
const putQueued = (recording) => queueTransaction('readwrite', (store) => store.put(recording));
|
||||
const deleteQueued = (id) => queueTransaction('readwrite', (store) => store.delete(id));
|
||||
|
||||
async function textTransaction(mode, operation) {
|
||||
const db = await openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(TEXT_STORE_NAME, mode);
|
||||
const request = operation(transaction.objectStore(TEXT_STORE_NAME));
|
||||
transaction.oncomplete = () => resolve(request?.result);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
const getTextRecords = () => textTransaction('readonly', (store) => store.getAll());
|
||||
const putTextRecord = (record) => textTransaction('readwrite', (store) => store.put(record));
|
||||
const deleteTextRecord = (id) => textTransaction('readwrite', (store) => store.delete(id));
|
||||
|
||||
function setCaptureMode(mode) {
|
||||
const writing = mode === 'write';
|
||||
elements['voice-mode'].classList.toggle('active', !writing);
|
||||
elements['write-mode'].classList.toggle('active', writing);
|
||||
elements['voice-mode'].setAttribute('aria-selected', String(!writing));
|
||||
elements['write-mode'].setAttribute('aria-selected', String(writing));
|
||||
elements['voice-panel'].hidden = writing;
|
||||
elements['write-panel'].hidden = !writing;
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
const text = elements['written-note'].value;
|
||||
try {
|
||||
await putTextRecord({ id: DRAFT_ID, text, updatedAt: Date.now() });
|
||||
elements['draft-status'].textContent = 'Draft saved on this device.';
|
||||
elements['save-written-note'].disabled = !text.trim();
|
||||
} catch {
|
||||
elements['draft-status'].textContent = 'Could not save draft in browser storage. Keep this page open.';
|
||||
elements['save-written-note'].disabled = !text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDraftSave() {
|
||||
elements['save-written-note'].disabled = !elements['written-note'].value.trim();
|
||||
elements['draft-status'].textContent = 'Saving draft…';
|
||||
clearTimeout(draftSaveTimer);
|
||||
draftSaveTimer = window.setTimeout(saveDraft, 250);
|
||||
}
|
||||
|
||||
async function queueWrittenNote() {
|
||||
const text = elements['written-note'].value.trim();
|
||||
if (!text || elements['save-written-note'].disabled) return;
|
||||
clearTimeout(draftSaveTimer);
|
||||
const createdAt = new Date().toISOString();
|
||||
const note = { id: crypto.randomUUID(), text, createdAt, createdAtMs: Date.now(), filename: 'Written note' };
|
||||
try {
|
||||
const db = await openDatabase();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(TEXT_STORE_NAME, 'readwrite');
|
||||
const store = transaction.objectStore(TEXT_STORE_NAME);
|
||||
store.put(note);
|
||||
store.delete(DRAFT_ID);
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
elements['written-note'].value = '';
|
||||
elements['draft-status'].textContent = navigator.onLine ? 'Saved on this device. Sending to the server…' : 'Saved on this device. It will send when you reconnect.';
|
||||
elements['save-written-note'].disabled = true;
|
||||
await renderQueue();
|
||||
await syncQueue();
|
||||
} catch {
|
||||
elements['draft-status'].textContent = 'Could not queue this note. Your draft is still here; try again.';
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreDraft() {
|
||||
try {
|
||||
const records = await getTextRecords();
|
||||
const draft = records.find((record) => record.id === DRAFT_ID);
|
||||
if (draft?.text) {
|
||||
elements['written-note'].value = draft.text;
|
||||
elements['draft-status'].textContent = 'Draft restored from this device.';
|
||||
elements['save-written-note'].disabled = false;
|
||||
setCaptureMode('write');
|
||||
}
|
||||
} catch {
|
||||
elements['draft-status'].textContent = 'Browser storage is unavailable. This draft may not survive leaving the page.';
|
||||
}
|
||||
}
|
||||
|
||||
function setMessage(message, isError = false, isSuccess = false) {
|
||||
elements['capture-message'].textContent = message;
|
||||
elements['capture-message'].style.color = isError ? 'var(--coral)' : '';
|
||||
@@ -231,12 +324,28 @@ async function syncQueue() {
|
||||
const response = await fetch('/api/notes', { method: 'POST', body: form });
|
||||
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
|
||||
await deleteQueued(recording.id);
|
||||
setMessage('✓', false, true);
|
||||
setMessage('Upload complete', false, true);
|
||||
} catch {
|
||||
setMessage('Upload paused. Your recording is still saved on this device.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
const textNotes = (await getTextRecords()).filter((record) => record.id !== DRAFT_ID).sort((a, b) => a.createdAtMs - b.createdAtMs);
|
||||
for (const note of textNotes) {
|
||||
try {
|
||||
const response = await fetch('/api/text-notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: note.id, text: note.text, created_at: note.createdAt }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Save failed: ${response.status}`);
|
||||
await deleteTextRecord(note.id);
|
||||
elements['draft-status'].textContent = 'Note saved on the server.';
|
||||
} catch {
|
||||
elements['draft-status'].textContent = 'Upload paused. Your note is still saved on this device.';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
syncing = false;
|
||||
elements['sync-button'].disabled = false;
|
||||
@@ -250,7 +359,9 @@ function prettyDate(value) {
|
||||
}
|
||||
|
||||
async function renderQueue() {
|
||||
const recordings = (await getQueued()).sort((a, b) => b.createdAtMs - a.createdAtMs);
|
||||
const audioRecords = await getQueued();
|
||||
const textRecords = (await getTextRecords()).filter((record) => record.id !== DRAFT_ID);
|
||||
const recordings = [...audioRecords, ...textRecords].sort((a, b) => b.createdAtMs - a.createdAtMs);
|
||||
elements['queue-list'].replaceChildren();
|
||||
elements['queue-count'].textContent = recordings.length;
|
||||
elements['queue-empty'].hidden = recordings.length > 0;
|
||||
@@ -259,7 +370,8 @@ async function renderQueue() {
|
||||
item.className = 'queue-item';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'queue-icon';
|
||||
icon.textContent = '◖';
|
||||
const isTextNote = 'text' in recording;
|
||||
icon.textContent = isTextNote ? 'T' : '◖';
|
||||
const body = document.createElement('div');
|
||||
body.className = 'item-body';
|
||||
const top = document.createElement('div');
|
||||
@@ -279,7 +391,8 @@ async function renderQueue() {
|
||||
if (syncing) return;
|
||||
remove.disabled = true;
|
||||
try {
|
||||
await deleteQueued(recording.id);
|
||||
if (isTextNote) await deleteTextRecord(recording.id);
|
||||
else await deleteQueued(recording.id);
|
||||
await renderQueue();
|
||||
} catch {
|
||||
setMessage('Could not remove the queued recording.', true);
|
||||
@@ -306,8 +419,8 @@ function createDeleteButton(label) {
|
||||
function renderArchive(notes) {
|
||||
elements['archive-list'].replaceChildren();
|
||||
elements['archive-empty'].hidden = notes.length > 0;
|
||||
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view notes awaiting transcription.';
|
||||
else elements['archive-empty'].textContent = 'No notes awaiting transcription.';
|
||||
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view saved notes.';
|
||||
else elements['archive-empty'].textContent = 'No saved notes yet.';
|
||||
for (const note of notes) {
|
||||
const item = document.createElement('article');
|
||||
item.className = 'archive-item';
|
||||
@@ -319,15 +432,16 @@ function renderArchive(notes) {
|
||||
const name = document.createElement('div');
|
||||
name.className = 'item-name';
|
||||
name.textContent = note.filename;
|
||||
const isWrittenNote = note.status === 'written';
|
||||
const transcript = document.createElement('textarea');
|
||||
transcript.className = 'archive-transcript';
|
||||
transcript.placeholder = note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.';
|
||||
transcript.placeholder = isWrittenNote ? 'Write or edit your note…' : note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.';
|
||||
transcript.value = note.transcript;
|
||||
transcript.setAttribute('aria-label', `Transcript for ${note.filename}`);
|
||||
transcript.setAttribute('aria-label', `${isWrittenNote ? 'Note' : 'Transcript'} for ${note.filename}`);
|
||||
const save = document.createElement('button');
|
||||
save.className = 'save-transcript';
|
||||
save.type = 'button';
|
||||
save.textContent = 'SAVE TRANSCRIPT';
|
||||
save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE TRANSCRIPT';
|
||||
save.addEventListener('click', async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
@@ -344,7 +458,7 @@ function renderArchive(notes) {
|
||||
});
|
||||
const status = document.createElement('div');
|
||||
status.className = 'archive-status';
|
||||
status.textContent = 'AWAITING TRANSCRIPTION';
|
||||
status.textContent = isWrittenNote ? 'WRITTEN NOTE' : note.status === 'transcribing' ? 'TRANSCRIBING' : note.status === 'transcribed' ? 'TRANSCRIPT SAVED' : 'AWAITING TRANSCRIPTION';
|
||||
const remove = createDeleteButton(`Delete note ${note.filename}`);
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
@@ -369,9 +483,14 @@ async function loadAwaitingNotes() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/notes?status=awaiting_transcription');
|
||||
if (!response.ok) throw new Error('Archive unavailable');
|
||||
renderArchive(await response.json());
|
||||
const responses = await Promise.all([
|
||||
fetch('/api/notes?status=awaiting_transcription'),
|
||||
fetch('/api/notes?status=written'),
|
||||
]);
|
||||
if (responses.some((response) => !response.ok)) throw new Error('Archive unavailable');
|
||||
const notes = (await Promise.all(responses.map((response) => response.json()))).flat();
|
||||
notes.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
renderArchive(notes);
|
||||
} catch {
|
||||
elements['archive-list'].replaceChildren();
|
||||
elements['archive-empty'].hidden = false;
|
||||
@@ -380,6 +499,14 @@ async function loadAwaitingNotes() {
|
||||
}
|
||||
|
||||
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
|
||||
elements['voice-mode'].addEventListener('click', () => setCaptureMode('voice'));
|
||||
elements['write-mode'].addEventListener('click', () => setCaptureMode('write'));
|
||||
elements['written-note'].addEventListener('input', scheduleDraftSave);
|
||||
elements['save-written-note'].addEventListener('click', queueWrittenNote);
|
||||
window.addEventListener('pagehide', () => {
|
||||
clearTimeout(draftSaveTimer);
|
||||
saveDraft();
|
||||
});
|
||||
elements['audio-file-input'].addEventListener('change', async (event) => {
|
||||
for (const file of event.target.files) await queueAudioFile(file);
|
||||
event.target.value = '';
|
||||
@@ -393,5 +520,6 @@ document.addEventListener('visibilitychange', () => { if (document.visibilitySta
|
||||
|
||||
updateNetwork();
|
||||
renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true));
|
||||
restoreDraft();
|
||||
loadAwaitingNotes();
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
+15
-2
@@ -19,6 +19,11 @@
|
||||
|
||||
<main>
|
||||
<section class="capture" aria-labelledby="capture-title">
|
||||
<div class="capture-modes" role="tablist" aria-label="Note type">
|
||||
<button id="voice-mode" class="capture-mode active" type="button" role="tab" aria-selected="true" aria-controls="voice-panel">Voice</button>
|
||||
<button id="write-mode" class="capture-mode" type="button" role="tab" aria-selected="false" aria-controls="write-panel">Write</button>
|
||||
</div>
|
||||
<div id="voice-panel" role="tabpanel" aria-labelledby="voice-mode">
|
||||
<div class="recorder-row">
|
||||
<button id="record-button" class="record-button" type="button" aria-label="Start recording">
|
||||
<span class="record-dot"></span>
|
||||
@@ -35,10 +40,18 @@
|
||||
<div class="audio-upload-row">
|
||||
<label class="audio-upload-button" for="audio-file-input">↑ Upload audio</label>
|
||||
<input id="audio-file-input" type="file" accept="audio/*,.mp3,.m4a,.aac,.ogg,.opus,.flac,.wav" multiple />
|
||||
<span>Files stay on this device until the server confirms storage.</span>
|
||||
</div>
|
||||
<div class="record-meter"><span id="record-meter-fill"></span></div>
|
||||
<div id="capture-message" class="capture-message" role="status"></div>
|
||||
</div>
|
||||
<div id="write-panel" class="write-panel" role="tabpanel" aria-labelledby="write-mode" hidden>
|
||||
<label class="write-label" for="written-note">Write a note</label>
|
||||
<textarea id="written-note" class="written-note" placeholder="What’s on your mind?" rows="7"></textarea>
|
||||
<div class="write-actions">
|
||||
<span id="draft-status" class="draft-status" role="status">Draft stays on this device until you save it.</span>
|
||||
<button id="save-written-note" class="save-written-note" type="button" disabled>Save note</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="queue-section" aria-labelledby="queue-title">
|
||||
@@ -52,7 +65,7 @@
|
||||
|
||||
<section class="archive-section" aria-labelledby="archive-title">
|
||||
<div class="section-heading archive-heading">
|
||||
<div><span class="section-kicker">SERVER STORED</span><h2 id="archive-title">Awaiting transcription</h2></div>
|
||||
<div><span class="section-kicker">SERVER STORED</span><h2 id="archive-title">Saved notes</h2></div>
|
||||
</div>
|
||||
<div id="archive-list" class="archive-list"></div>
|
||||
<p id="archive-empty" class="empty-state">Notes awaiting transcription will appear here.</p>
|
||||
|
||||
+19
-4
@@ -24,6 +24,17 @@ body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(
|
||||
.network-state.online i { background: #57986b; }
|
||||
main { max-width: 1040px; margin: auto; padding: 0 24px; }
|
||||
.capture { padding: 54px 0 40px; border-bottom: 1px solid var(--line); background-image: radial-gradient(#c7c9bf 0.55px, transparent 0.55px); background-size: 9px 9px; background-position: right 22px top 28px; background-repeat: no-repeat; }
|
||||
.capture-modes { display: flex; width: fit-content; margin: 0 auto 24px; border-bottom: 1px solid var(--line); }
|
||||
.capture-mode { min-width: 108px; min-height: 40px; border: 0; border-bottom: 2px solid transparent; padding: 0 16px; color: var(--muted); background: transparent; font: 12px var(--mono); cursor: pointer; }
|
||||
.capture-mode.active { border-bottom-color: var(--moss); color: var(--moss-dark); }
|
||||
.write-panel { max-width: 590px; margin: auto; }
|
||||
.write-label { display: block; margin: 0 0 10px; font: 500 20px var(--serif); }
|
||||
.written-note { display: block; width: 100%; min-height: 190px; resize: vertical; border: 1px solid var(--line); padding: 14px; color: var(--ink); background: #fffefa; font: 14px/1.6 var(--sans); }
|
||||
.written-note:focus { outline: 2px solid var(--moss); outline-offset: 2px; }
|
||||
.write-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 12px; }
|
||||
.draft-status { color: var(--muted); font-size: 11px; }
|
||||
.save-written-note { min-height: 40px; flex: 0 0 auto; border: 1px solid var(--moss-dark); padding: 0 16px; color: white; background: var(--moss-dark); font: 12px var(--mono); cursor: pointer; }
|
||||
.save-written-note:disabled { opacity: .45; cursor: default; }
|
||||
.section-kicker { display: flex; justify-content: space-between; color: var(--muted); font: 10px var(--mono); letter-spacing: 0; }
|
||||
h1 { margin: 24px 0 11px; font: 500 clamp(42px, 7vw, 72px)/0.99 var(--serif); letter-spacing: 0; }
|
||||
h1 em { color: var(--moss); font-weight: 500; }
|
||||
@@ -43,9 +54,10 @@ h1 em { color: var(--moss); font-weight: 500; }
|
||||
#record-time { color: var(--muted); font: 12px var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.record-meter { height: 2px; max-width: 590px; margin-inline: auto; background: #e4e3dc; }
|
||||
.record-meter span { display: block; height: 100%; width: 0; background: var(--coral); transition: width .25s linear; }
|
||||
.capture-message { height: 20px; max-width: 590px; margin-inline: auto; padding-top: 6px; color: var(--muted); font-size: 11px; text-align: center; }
|
||||
.capture-message.upload-confirmed { color: forestgreen; font-size: 16px; }
|
||||
.audio-upload-row { max-width: 590px; margin: 14px auto 0; display: flex; align-items: center; justify-content: center; gap: 12px; }
|
||||
.capture-message { min-height: 20px; max-width: 590px; margin: 8px auto 0; padding-top: 6px; color: var(--muted); font-size: 11px; text-align: center; }
|
||||
.capture-message.upload-confirmed { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; color: forestgreen; font-size: 16px; }
|
||||
.capture-message.upload-confirmed::after { content: '✓'; color: forestgreen; font-size: 1.05em; line-height: 1; }
|
||||
.audio-upload-row { max-width: 590px; margin: 14px auto 6px; display: flex; align-items: center; justify-content: center; gap: 12px; }
|
||||
.audio-upload-button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; border: 1px solid var(--line); padding: 0 12px; color: var(--moss-dark); background: #fffefa; font-size: 12px; cursor: pointer; }
|
||||
.audio-upload-button:hover { border-color: var(--moss); }
|
||||
#audio-file-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }
|
||||
@@ -81,12 +93,15 @@ footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(saf
|
||||
.topbar { height: 58px; padding: 0 18px; }
|
||||
main { padding: 0 18px; }
|
||||
.capture { padding: 38px 0 30px; background-position: right 8px top 25px; }
|
||||
.capture-mode { min-width: 100px; }
|
||||
.write-actions { align-items: flex-start; }
|
||||
h1 { font-size: 48px; }
|
||||
.recorder-row { gap: 12px; padding-right: 12px; }
|
||||
.record-button { width: 46px; height: 46px; }
|
||||
.record-copy strong { font-size: 12px; }
|
||||
.record-copy span { font-size: 10px; }
|
||||
.audio-upload-row { align-items: flex-start; flex-direction: column; }
|
||||
.audio-upload-row { align-items: center; flex-direction: column; }
|
||||
.audio-upload-row > span { text-align: center; }
|
||||
h2 { font-size: 23px; }
|
||||
.archive-date { width: 60px; font-size: 9px; }
|
||||
footer { padding-left: 18px; padding-right: 18px; font-size: 8px; }
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'faerro-kb-shell-v2';
|
||||
const CACHE_NAME = 'faerro-kb-shell-v3';
|
||||
const APP_SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/favicon.svg'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
image="gitea.faerro.it/andrea/faerro-kb"
|
||||
domain="registry.gitea.faerro.it"
|
||||
image="$domain/andrea/faerro-kb"
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
bump="patch"
|
||||
|
||||
@@ -102,7 +103,7 @@ if [[ -n "$registry_username" || -n "$registry_token" ]]; then
|
||||
echo "Set both GITEA_USERNAME and GITEA_TOKEN in .env to log in automatically." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! printf '%s' "$registry_token" | docker login --username "$registry_username" --password-stdin gitea.faerro.it; then
|
||||
if ! printf '%s' "$registry_token" | docker login --username "$registry_username" --password-stdin "$domain"; then
|
||||
unset registry_token
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user