wayBetter for X

This commit is contained in:
CRTkafa 2026-10-01 06:01:13 +07:00
commit df5dff619e
720 changed files with 147465 additions and 0 deletions

View 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>;
}