wayBetter for X
This commit is contained in:
commit
df5dff619e
720 changed files with 147465 additions and 0 deletions
120
apps/desktop/src/ui/Drafts.tsx
Normal file
120
apps/desktop/src/ui/Drafts.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// The drafts vault: a section of the wayBetter panel (createPanel, src/ui/Panel.tsx). Text typed in
|
||||
// X's composer is captured natively with every revision kept; here the owner searches, edits,
|
||||
// archives, compares and restores revisions, and exports everything as JSON.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { EmptyState } from '../../../../waybetter/packages/shell/src/panel/controls.tsx';
|
||||
import { compareLines } from '../../../../waybetter/packages/shell/src/panel/compare.ts';
|
||||
import type { SectionProps } from '../../../../waybetter/packages/shell/src/panel/site.ts';
|
||||
import { bridge } from '../bridge/index';
|
||||
import type { DraftRevision } from '../core/types';
|
||||
import { formatDate, useT, type MessageKey } from '../i18n/index.ts';
|
||||
import { useDrafts, type RecordState } from './useDrafts';
|
||||
|
||||
const getError = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||
const when = (value: string) => formatDate(value);
|
||||
const DRAFT_ICON = <svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20l1-4L16 5l3 3L8 19zM14 7l3 3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/></svg>;
|
||||
|
||||
const STATE_KEYS: Record<RecordState['state'], MessageKey> = {
|
||||
saved: 'panel.drafts.state.saved', pending: 'panel.drafts.state.pending', saving: 'panel.drafts.state.saving', conflict: 'panel.drafts.state.conflict', error: 'panel.drafts.state.error',
|
||||
};
|
||||
const KIND_KEYS: Record<string, MessageKey> = { post: 'panel.drafts.kind.post', reply: 'panel.drafts.kind.reply', quote: 'panel.drafts.kind.quote', thread: 'panel.drafts.kind.thread' };
|
||||
|
||||
export function Drafts({ notify }: SectionProps) {
|
||||
const t = useT();
|
||||
const stateLabel = (record: RecordState) => t(STATE_KEYS[record.state] ?? 'panel.drafts.state.error');
|
||||
const drafts = useDrafts();
|
||||
const [query, setQuery] = useState('');
|
||||
const [archived, setArchived] = useState(false);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [revisions, setRevisions] = useState<DraftRevision[] | null>(null);
|
||||
const [compare, setCompare] = useState<DraftRevision | null>(null);
|
||||
const all = [...drafts.records.values()];
|
||||
const visible = all
|
||||
.filter(r => (r.ack.status === 'archived') === archived && `${r.value.title} ${r.value.text}`.toLowerCase().includes(query.trim().toLowerCase()))
|
||||
.sort((a, b) => b.ack.updatedAt.localeCompare(a.ack.updatedAt));
|
||||
const active = selected ? drafts.records.get(selected) : undefined;
|
||||
const diff = useMemo(() => compare && active ? compareLines(compare.text, active.value.text) : null, [compare, active?.value.text]);
|
||||
|
||||
useEffect(() => { setRevisions(null); setCompare(null); }, [selected]);
|
||||
async function loadHistory() {
|
||||
if (!active) return;
|
||||
try { setRevisions((await bridge.getRevisions(active.ack.id)).sort((a, b) => b.revision - a.revision)); }
|
||||
catch (error) { notify(getError(error), true); }
|
||||
}
|
||||
async function create() {
|
||||
try { const id = await drafts.create('post'); setArchived(false); setQuery(''); setSelected(id); }
|
||||
catch (error) { notify(getError(error), true); }
|
||||
}
|
||||
|
||||
if (active) {
|
||||
const archivedNow = active.ack.status === 'archived';
|
||||
return <section className="btp-draft">
|
||||
<div className="btp-draft-bar">
|
||||
<button className="btp-link" onClick={() => setSelected(null)}>{t('panel.drafts.all')}</button>
|
||||
<span className={`btp-save is-${active.state}`} role="status">{stateLabel(active)} · r{active.ack.revision}</span>
|
||||
</div>
|
||||
<input className="btp-input btp-draft-title" aria-label={t('panel.drafts.titleLabel')} placeholder={t('panel.drafts.titlePlaceholder')} maxLength={120} value={active.value.title}
|
||||
disabled={drafts.busy === active.ack.id} onChange={event => drafts.edit(active.ack.id, { title: event.target.value })}/>
|
||||
<textarea className="btp-input btp-draft-text" aria-label={t('panel.drafts.textLabel')} spellCheck value={active.value.text}
|
||||
disabled={drafts.busy === active.ack.id} onChange={event => drafts.edit(active.ack.id, { text: event.target.value })}/>
|
||||
{(active.state === 'error' || active.state === 'conflict') && <div className="btp-alert" role="alert">
|
||||
{active.state === 'conflict' ? t('panel.drafts.conflict') : active.error}
|
||||
{active.state === 'conflict'
|
||||
? <button className="btp-btn" onClick={() => void drafts.recoverConflict(active.ack.id).then(setSelected).catch(error => notify(getError(error), true))}>{t('panel.drafts.keepAsNew')}</button>
|
||||
: <button className="btp-btn" onClick={() => void drafts.flush(active.ack.id)}>{t('panel.drafts.retrySave')}</button>}
|
||||
</div>}
|
||||
<div className="btp-row-actions">
|
||||
<span className="btp-muted">{t('panel.drafts.characters', { count: Array.from(active.value.text).length })}</span>
|
||||
<button className="btp-btn" onClick={() => void navigator.clipboard.writeText(active.value.text).then(() => notify(t('common.copied')), () => notify(t('common.clipboardUnavailable'), true))}>{t('common.copy')}</button>
|
||||
<button className="btp-btn" onClick={() => void loadHistory()}>{t('panel.drafts.history')}</button>
|
||||
<button className="btp-btn" disabled={drafts.busy === active.ack.id} onClick={() => void drafts.setStatus(active.ack.id, archivedNow ? 'active' : 'archived').then(() => notify(archivedNow ? t('panel.drafts.restored') : t('panel.drafts.archived'))).catch(error => notify(getError(error), true))}>{archivedNow ? t('panel.drafts.unarchive') : t('panel.drafts.archive')}</button>
|
||||
</div>
|
||||
{revisions && <div className="btp-list">
|
||||
<div className="btp-list-head">{t('panel.drafts.history')} <span>{revisions.length}</span></div>
|
||||
{revisions.map(revision => <button key={revision.revision} className={`btp-item ${compare?.revision === revision.revision ? 'is-on' : ''}`} onClick={() => setCompare(revision)}>
|
||||
<span className="btp-item-top"><strong>{t('panel.drafts.revision', { n: revision.revision })}</strong><small>{when(revision.createdAt)}</small></span>
|
||||
<span className="btp-item-text">{revision.text.slice(0, 120) || t('panel.drafts.empty')}</span>
|
||||
</button>)}
|
||||
</div>}
|
||||
{compare && diff && <div className="btp-diff">
|
||||
<div className="btp-list-head">{t('panel.drafts.compare', { n: compare.revision })}</div>
|
||||
<pre>{diff.previous.map((line, index) => <span key={`a${index}`} className={line.changed ? 'is-del' : ''}>{line.text || ' '}</span>)}</pre>
|
||||
<pre>{diff.current.map((line, index) => <span key={`b${index}`} className={line.changed ? 'is-add' : ''}>{line.text || ' '}</span>)}</pre>
|
||||
<div className="btp-row-actions">
|
||||
<button className="btp-btn" onClick={() => setCompare(null)}>{t('common.close')}</button>
|
||||
<button className="btp-btn is-primary" disabled={active.state === 'conflict' || drafts.busy === active.ack.id}
|
||||
onClick={() => void drafts.restore(active.ack.id, compare).then(() => { setCompare(null); setRevisions(null); notify(t('panel.drafts.restoredRevision')); }).catch(error => notify(getError(error), true))}>{t('panel.drafts.restoreVersion')}</button>
|
||||
</div>
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
const count = (status: 'active' | 'archived') => all.filter(r => r.ack.status === status).length;
|
||||
return <section className="btp-group">
|
||||
<div className="btp-toolbar">
|
||||
<input className="btp-input" type="search" aria-label={t('panel.drafts.search')} placeholder={t('panel.drafts.search')} value={query} onChange={event => setQuery(event.target.value)}/>
|
||||
<button className="btp-btn is-primary" disabled={drafts.loading} onClick={() => void create()}>{t('panel.drafts.new')}</button>
|
||||
</div>
|
||||
<div className="btp-seg" role="tablist">
|
||||
<button role="tab" aria-selected={!archived} className={!archived ? 'is-on' : ''} onClick={() => setArchived(false)}>{t('panel.section.drafts')} <span>{count('active')}</span></button>
|
||||
<button role="tab" aria-selected={archived} className={archived ? 'is-on' : ''} onClick={() => setArchived(true)}>{t('panel.drafts.archiveTab')} <span>{count('archived')}</span></button>
|
||||
</div>
|
||||
{drafts.loadError && <div className="btp-alert" role="alert">{drafts.loadError}<button className="btp-btn" onClick={() => void drafts.refresh()}>{t('common.retry')}</button></div>}
|
||||
<div className="btp-list">
|
||||
{drafts.loading && <p className="btp-empty">{t('common.loading')}</p>}
|
||||
{!drafts.loading && !visible.length && <div className="bt-card"><EmptyState icon={DRAFT_ICON} title={query ? t('panel.drafts.noMatch') : archived ? t('panel.drafts.noArchived') : t('panel.drafts.none')}>
|
||||
{query ? t('panel.drafts.noMatchHint') : archived ? t('panel.drafts.noArchivedHint') : t('panel.drafts.noneHint')}
|
||||
</EmptyState></div>}
|
||||
{visible.map(record => <button key={record.ack.id} className="btp-item" onClick={() => setSelected(record.ack.id)}>
|
||||
<span className="btp-item-top"><strong>{record.value.title.trim() || record.value.text.trim().split('\n')[0]?.slice(0, 60) || t('panel.drafts.untitled')}</strong><small>{when(record.ack.updatedAt)}</small></span>
|
||||
<span className="btp-item-text">{record.value.text.slice(0, 160) || t('panel.drafts.empty')}</span>
|
||||
<span className="btp-item-meta"><em>{KIND_KEYS[record.ack.kind] ? t(KIND_KEYS[record.ack.kind]!) : record.ack.kind}</em><em>{record.ack.source === 'capture' ? t('panel.drafts.fromX') : record.ack.source === 'local' ? t('panel.drafts.local') : record.ack.source === 'template' ? t('panel.drafts.template') : record.ack.source}</em>{record.state !== 'saved' && <em className="is-warn">{stateLabel(record)}</em>}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
{!archived && all.length > 0 && <button className="btp-link" onClick={() => void drafts.flushAll().then(() => bridge.exportData()).then(data => {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
|
||||
const anchor = document.createElement('a'); anchor.href = url; anchor.download = `waybetter-drafts-${new Date().toISOString().slice(0, 10)}.json`; anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}).catch(error => notify(getError(error), true))}>{t('panel.drafts.export')}</button>}
|
||||
</section>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue