mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +02:00
Compare commits
No commits in common. "4311e85f95302dbde111595388a1816a82781f91" and "46ad816c4c055c5370ee694d3f883f5526b91833" have entirely different histories.
4311e85f95
...
46ad816c4c
32 changed files with 1084 additions and 3554 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -23,10 +23,3 @@
|
||||||
!frontend/**
|
!frontend/**
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/node_modules/**
|
frontend/node_modules/**
|
||||||
frontend/result
|
|
||||||
frontend/result/**
|
|
||||||
frontend/dist/
|
|
||||||
frontend/dist/**
|
|
||||||
.DS_Store
|
|
||||||
frontend/storybook-static/
|
|
||||||
frontend/storybook-static/**
|
|
||||||
|
|
|
||||||
40
crates/archivr-server/static/assets/index-BfwsUa2j.js
Normal file
40
crates/archivr-server/static/assets/index-BfwsUa2j.js
Normal file
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-C6h0jcH3.js
Normal file
40
crates/archivr-server/static/assets/index-C6h0jcH3.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-CcezQ0kg.js
Normal file
40
crates/archivr-server/static/assets/index-CcezQ0kg.js
Normal file
File diff suppressed because one or more lines are too long
1
crates/archivr-server/static/assets/index-D40QFUPh.css
Normal file
1
crates/archivr-server/static/assets/index-D40QFUPh.css
Normal file
File diff suppressed because one or more lines are too long
1
crates/archivr-server/static/assets/index-DJpQthbx.css
Normal file
1
crates/archivr-server/static/assets/index-DJpQthbx.css
Normal file
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-DRRKzKIq.js
Normal file
40
crates/archivr-server/static/assets/index-DRRKzKIq.js
Normal file
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-Davzo15o.js
Normal file
40
crates/archivr-server/static/assets/index-Davzo15o.js
Normal file
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-Dfnwsgsa.js
Normal file
40
crates/archivr-server/static/assets/index-Dfnwsgsa.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Archivr</title>
|
<title>Archivr</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CWb0sQjJ.js"></script>
|
<script type="module" crossorigin src="/assets/index-C6h0jcH3.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-MrdP6h9x.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D40QFUPh.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/** @type { import('@storybook/react-vite').StorybookConfig } */
|
|
||||||
const config = {
|
|
||||||
stories: ['../src/**/*.stories.{js,jsx,ts,tsx}'],
|
|
||||||
addons: [
|
|
||||||
'@storybook/addon-essentials',
|
|
||||||
'@storybook/addon-interactions',
|
|
||||||
],
|
|
||||||
framework: {
|
|
||||||
name: '@storybook/react-vite',
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
export default config;
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import '../src/styles.css';
|
|
||||||
|
|
||||||
export const parameters = {
|
|
||||||
layout: 'fullscreen',
|
|
||||||
};
|
|
||||||
1892
frontend/bun.lock
1892
frontend/bun.lock
File diff suppressed because it is too large
Load diff
|
|
@ -6,24 +6,14 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"storybook": "storybook dev -p 6006",
|
|
||||||
"storybook:build": "storybook build"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/addon-essentials": "^7.6.20",
|
|
||||||
"@storybook/addon-interactions": "^7.6.20",
|
|
||||||
"@storybook/blocks": "^7.6.20",
|
|
||||||
"@storybook/preview-api": "^7.6.20",
|
|
||||||
"@storybook/react": "^7.6.20",
|
|
||||||
"@storybook/react-vite": "^7.6.20",
|
|
||||||
"@storybook/test": "^7.6.20",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"storybook": "^7.6.20",
|
|
||||||
"vite": "^5.4.11"
|
"vite": "^5.4.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,32 +172,23 @@ export default function App() {
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<div className="workspace">
|
<div className="workspace">
|
||||||
{view === 'archive' && (
|
{view === 'archive' && (
|
||||||
<div className="toolbar">
|
<div className="search-row">
|
||||||
<div className="search-field">
|
|
||||||
<span className="ico" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
||||||
<circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<input
|
<input
|
||||||
className="search-input"
|
className="search-input"
|
||||||
type="search"
|
type="search"
|
||||||
aria-label="Search archive"
|
aria-label="Search archive"
|
||||||
aria-busy={searchBusy}
|
aria-busy={searchBusy}
|
||||||
placeholder="Search titles, URLs, types, tags…"
|
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className="kbd">⌘K</span>
|
<div className="result-count">
|
||||||
</div>
|
{resultCount}
|
||||||
<span className="result-count">
|
|
||||||
{resultCount && <><b>{resultCount.split(' ')[0]}</b>{' '}{resultCount.split(' ').slice(1).join(' ')}</>}
|
|
||||||
{tagFilter && (
|
{tagFilter && (
|
||||||
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
|
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
|
||||||
× {tagFilter}
|
× {tagFilter}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{view === 'archive' && (
|
{view === 'archive' && (
|
||||||
|
|
|
||||||
|
|
@ -114,13 +114,13 @@ export default function AdminView({ archives }) {
|
||||||
<section id="admin-view" className="view admin-view is-active">
|
<section id="admin-view" className="view admin-view is-active">
|
||||||
<h1>Admin</h1>
|
<h1>Admin</h1>
|
||||||
|
|
||||||
<div className="view-tabs">
|
<div className="admin-tabs">
|
||||||
<button className={`view-tab${tab === 'users' ? ' is-active' : ''}`} onClick={() => setTab('users')}>Users</button>
|
<button className={`admin-tab${tab === 'users' ? ' is-active' : ''}`} onClick={() => setTab('users')}>Users</button>
|
||||||
<button className={`view-tab${tab === 'roles' ? ' is-active' : ''}`} onClick={() => setTab('roles')}>Roles</button>
|
<button className={`admin-tab${tab === 'roles' ? ' is-active' : ''}`} onClick={() => setTab('roles')}>Roles</button>
|
||||||
<button className={`view-tab${tab === 'archives' ? ' is-active' : ''}`} onClick={() => setTab('archives')}>Archives</button>
|
<button className={`admin-tab${tab === 'archives' ? ' is-active' : ''}`} onClick={() => setTab('archives')}>Archives</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="form-msg form-msg--err">{error}</div>}
|
{error && <div className="capture-error">{error}</div>}
|
||||||
|
|
||||||
{tab === 'users' && (
|
{tab === 'users' && (
|
||||||
<div className="admin-section">
|
<div className="admin-section">
|
||||||
|
|
@ -156,9 +156,9 @@ export default function AdminView({ archives }) {
|
||||||
value={newPassword} onChange={e => setNewPassword(e.target.value)} required />
|
value={newPassword} onChange={e => setNewPassword(e.target.value)} required />
|
||||||
<input className="admin-input" type="email" placeholder="Email (optional)"
|
<input className="admin-input" type="email" placeholder="Email (optional)"
|
||||||
value={newEmail} onChange={e => setNewEmail(e.target.value)} />
|
value={newEmail} onChange={e => setNewEmail(e.target.value)} />
|
||||||
{createError && <div className="form-msg form-msg--err">{createError}</div>}
|
{createError && <div className="capture-error">{createError}</div>}
|
||||||
<button className="btn-primary" type="submit" disabled={creating}>
|
<button className="capture-submit" type="submit" disabled={creating}>
|
||||||
{creating ? 'Creating\u2026' : 'Create User'}
|
{creating ? 'Creating…' : 'Create User'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -192,9 +192,9 @@ export default function AdminView({ archives }) {
|
||||||
onChange={e => setNewRoleSlug(e.target.value)} required />
|
onChange={e => setNewRoleSlug(e.target.value)} required />
|
||||||
<input className="admin-input" placeholder="Display Name (e.g. Moderator)"
|
<input className="admin-input" placeholder="Display Name (e.g. Moderator)"
|
||||||
value={newRoleName} onChange={e => setNewRoleName(e.target.value)} required />
|
value={newRoleName} onChange={e => setNewRoleName(e.target.value)} required />
|
||||||
{roleCreateError && <div className="form-msg form-msg--err">{roleCreateError}</div>}
|
{roleCreateError && <div className="capture-error">{roleCreateError}</div>}
|
||||||
<button className="btn-primary" type="submit" disabled={creatingRole}>
|
<button className="capture-submit" type="submit" disabled={creatingRole}>
|
||||||
{creatingRole ? 'Creating\u2026' : 'Create Role'}
|
{creatingRole ? 'Creating…' : 'Create Role'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import CaptureDialog from './CaptureDialog';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: CaptureDialog,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
};
|
|
||||||
|
|
||||||
function CaptureDialogWrapper(args) {
|
|
||||||
const [open, setOpen] = useState(args.open);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button onClick={() => setOpen(true)} style={{ padding: '8px 16px', marginBottom: '16px' }}>
|
|
||||||
Open Capture Dialog
|
|
||||||
</button>
|
|
||||||
<CaptureDialog
|
|
||||||
{...args}
|
|
||||||
open={open}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Default = {
|
|
||||||
render: (args) => <CaptureDialogWrapper {...args} />,
|
|
||||||
args: {
|
|
||||||
open: false,
|
|
||||||
archiveId: 'archive_1',
|
|
||||||
onCaptured: () => {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Open = {
|
|
||||||
args: {
|
|
||||||
open: true,
|
|
||||||
archiveId: 'archive_1',
|
|
||||||
onCaptured: () => {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -192,7 +192,7 @@ export default function CollectionsView({ archiveId }) {
|
||||||
{collections.map(c => (
|
{collections.map(c => (
|
||||||
<button
|
<button
|
||||||
key={c.collection_uid}
|
key={c.collection_uid}
|
||||||
className={`coll-sidebar-row${selectedUid === c.collection_uid ? ' is-active' : ''}`}
|
className={`coll-row${selectedUid === c.collection_uid ? ' is-active' : ''}`}
|
||||||
onClick={() => setSelectedUid(c.collection_uid)}
|
onClick={() => setSelectedUid(c.collection_uid)}
|
||||||
>
|
>
|
||||||
<span className="coll-row-name">{c.name}</span>
|
<span className="coll-row-name">{c.name}</span>
|
||||||
|
|
@ -324,43 +324,40 @@ export default function CollectionsView({ archiveId }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create form */}
|
{/* Create form */}
|
||||||
<details className="coll-create-details">
|
<details className="coll-create-details" style={{ marginTop: '1.5rem' }}>
|
||||||
<summary>+ Create collection</summary>
|
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Create collection</summary>
|
||||||
<form className="coll-create-form" onSubmit={handleCreate}>
|
<form onSubmit={handleCreate} style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: 400 }}>
|
||||||
<div className="form-field">
|
<label>
|
||||||
<label className="form-label" htmlFor="coll-name">Name</label>
|
Name
|
||||||
<input
|
<input
|
||||||
className="capture-input"
|
className="capture-input"
|
||||||
id="coll-name"
|
|
||||||
type="text"
|
type="text"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={e => { setNewName(e.target.value); if (!newSlug) setNewSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) }}
|
onChange={e => { setNewName(e.target.value); if (!newSlug) setNewSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) }}
|
||||||
placeholder="My Collection"
|
placeholder="My Collection"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
<div className="form-field">
|
<label>
|
||||||
<label className="form-label" htmlFor="coll-slug">Slug</label>
|
Slug
|
||||||
<input
|
<input
|
||||||
className="capture-input"
|
className="capture-input"
|
||||||
id="coll-slug"
|
|
||||||
type="text"
|
type="text"
|
||||||
value={newSlug}
|
value={newSlug}
|
||||||
onChange={e => setNewSlug(e.target.value)}
|
onChange={e => setNewSlug(e.target.value)}
|
||||||
placeholder="my-collection"
|
placeholder="my-collection"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
<div className="form-field">
|
<label>
|
||||||
<label className="form-label" htmlFor="coll-vis">Default visibility</label>
|
Default visibility
|
||||||
<select className="capture-input" id="coll-vis" style={{ height: 42 }}
|
<select className="capture-input" style={{ height: 42 }} value={newVis} onChange={e => setNewVis(Number(e.target.value))}>
|
||||||
value={newVis} onChange={e => setNewVis(Number(e.target.value))}>
|
|
||||||
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</label>
|
||||||
{createError && <div className="collections-error">{createError}</div>}
|
{createError && <div className="collections-error">{createError}</div>}
|
||||||
<button className="btn-primary" type="submit" disabled={creating}>
|
<button className="capture-submit" type="submit" disabled={creating} style={{ alignSelf: 'flex-start', padding: '8px 20px' }}>
|
||||||
{creating ? 'Creating\u2026' : 'Create collection'}
|
{creating ? 'Creating…' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections } from '../api'
|
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections } from '../api'
|
||||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils'
|
import { formatTimestamp, formatBytes, valueText } from '../utils'
|
||||||
|
|
||||||
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
|
||||||
|
|
||||||
const ExternalIcon = () => (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M7 17 17 7M9 7h8v8"/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) {
|
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) {
|
||||||
const [detail, setDetail] = useState(null)
|
const [detail, setDetail] = useState(null)
|
||||||
|
|
@ -61,60 +53,54 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
||||||
const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid)
|
const updated = await fetchEntryTags(archiveId, selectedEntry.entry_uid)
|
||||||
setTags(updated)
|
setTags(updated)
|
||||||
onTagsRefresh()
|
onTagsRefresh()
|
||||||
} catch {
|
} catch (e) {
|
||||||
// silently ignore
|
// silently ignore for now; could add error state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaRows = detail ? [
|
|
||||||
['Added', formatTimestamp(detail.summary.archived_at)],
|
|
||||||
['Source', detail.summary.source_kind],
|
|
||||||
['Type', detail.summary.entity_kind],
|
|
||||||
['Visibility', VIS_LABEL[detail.summary.visibility] ?? detail.summary.visibility],
|
|
||||||
['Root', detail.structured_root_relpath],
|
|
||||||
] : []
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="context-rail">
|
<aside className="context-rail">
|
||||||
<div className="rail-eyebrow">Context</div>
|
<div className="rail-title">Context</div>
|
||||||
|
|
||||||
{!selectedEntry ? (
|
{!selectedEntry ? (
|
||||||
<p className="tags-empty">Select an entry.</p>
|
<div className="rail-body">Select an entry.</div>
|
||||||
) : !detail ? (
|
) : !detail ? (
|
||||||
<p className="tags-empty">Loading…</p>
|
<div className="rail-body">Loading…</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="rail-body">
|
||||||
<h2 className="rail-title">
|
<strong className="rail-entry-title">
|
||||||
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
|
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
|
||||||
</h2>
|
</strong>
|
||||||
|
|
||||||
|
<div className="rail-section">
|
||||||
{detail.summary.original_url && (
|
{detail.summary.original_url && (
|
||||||
|
<div className="rail-item">
|
||||||
|
<span className="rail-label">Original URL</span>:{' '}
|
||||||
<a
|
<a
|
||||||
className="url-tile"
|
|
||||||
href={detail.summary.original_url}
|
href={detail.summary.original_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
className="rail-url-link"
|
||||||
>
|
>
|
||||||
<span className="ico" dangerouslySetInnerHTML={{ __html: sourceIconSvg(detail.summary.source_kind) }} />
|
{detail.summary.original_url}
|
||||||
<span className="u-text">{detail.summary.original_url}</span>
|
|
||||||
<span className="ext"><ExternalIcon /></span>
|
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{[
|
||||||
<div className="meta-list">
|
['Added', formatTimestamp(detail.summary.archived_at)],
|
||||||
{metaRows.filter(([, v]) => v != null && v !== '').map(([label, value]) => (
|
['Source', detail.summary.source_kind],
|
||||||
<div key={label} className="meta-item">
|
['Type', detail.summary.entity_kind],
|
||||||
<span className="meta-k">{label}</span>
|
['Visibility', detail.summary.visibility],
|
||||||
<span className={`meta-v${label === 'Root' ? ' mono' : ''}`}>{valueText(value)}</span>
|
['Structured root', detail.structured_root_relpath],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label} className="rail-item">
|
||||||
|
<span className="rail-label">{label}</span>: {valueText(value)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detail.artifacts.length > 0 && (
|
{detail.artifacts.length > 0 ? (
|
||||||
<div className="rail-section">
|
<div className="rail-section">
|
||||||
<div className="rail-section-heading">
|
<div className="rail-section-heading">Artifacts ({detail.artifacts.length})</div>
|
||||||
Artifacts <span className="num">{detail.artifacts.length}</span>
|
|
||||||
</div>
|
|
||||||
<ul className="artifact-list">
|
<ul className="artifact-list">
|
||||||
{detail.artifacts.map((artifact, index) => (
|
{detail.artifacts.map((artifact, index) => (
|
||||||
<li key={index}>
|
<li key={index}>
|
||||||
|
|
@ -124,65 +110,64 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="artifact-link"
|
className="artifact-link"
|
||||||
>
|
>
|
||||||
<span className="artifact-name">{artifact.artifact_role.replace(/_/g, ' ')}</span>
|
{artifact.artifact_role.replace(/_/g, ' ')}
|
||||||
<span className="artifact-size">
|
{artifact.byte_size != null ? ` (${formatBytes(artifact.byte_size)})` : ''}
|
||||||
{artifact.byte_size != null ? formatBytes(artifact.byte_size) : '—'}
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rail-item muted">No artifacts.</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedEntry && (
|
{selectedEntry && (
|
||||||
<>
|
<>
|
||||||
<div className="rail-section">
|
<div className="entry-tags">
|
||||||
<div className="rail-section-heading">Tags</div>
|
|
||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<p className="tags-empty">No tags yet.</p>
|
<span className="muted">No tags.</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="tags-wrap">
|
tags.map(tag => (
|
||||||
{tags.map(tag => (
|
|
||||||
<span key={tag.tag_uid} className="tag-pill" title={tag.full_path}>
|
<span key={tag.tag_uid} className="tag-pill" title={tag.full_path}>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
<button
|
<button
|
||||||
className="remove"
|
className="remove-tag"
|
||||||
title={`Remove tag ${tag.full_path}`}
|
title={`Remove tag ${tag.full_path}`}
|
||||||
onClick={() => handleRemoveTag(tag.tag_uid)}
|
onClick={() => handleRemoveTag(tag.tag_uid)}
|
||||||
>×</button>
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="assign-tag-form">
|
||||||
{assignError && (
|
|
||||||
<p className="form-msg form-msg--err" style={{ margin: '0 0 8px' }}>{assignError}</p>
|
|
||||||
)}
|
|
||||||
<div className="tag-input-wrap">
|
|
||||||
<span className="hash">/</span>
|
|
||||||
<input
|
<input
|
||||||
className="tag-input"
|
className="assign-tag-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="science/cs"
|
placeholder="/science/cs"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={assignInput}
|
value={assignInput}
|
||||||
onChange={e => setAssignInput(e.target.value)}
|
onChange={e => setAssignInput(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleAssignTag() }}
|
onKeyDown={e => { if (e.key === 'Enter') handleAssignTag() }}
|
||||||
/>
|
/>
|
||||||
<button className="tag-add-btn" onClick={handleAssignTag}>Add</button>
|
<button className="assign-tag-btn" onClick={handleAssignTag}>Add tag</button>
|
||||||
|
{assignError && (
|
||||||
|
<div className="muted" style={{ fontSize: '0.85em', color: 'var(--accent)' }}>
|
||||||
|
{assignError}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{selectedEntry && entryCollections.length > 0 && (
|
||||||
{entryCollections.length > 0 && (
|
|
||||||
<div className="rail-section">
|
<div className="rail-section">
|
||||||
<div className="rail-section-heading">Collections</div>
|
<div className="rail-section-heading">Collections</div>
|
||||||
{entryCollections.map(c => (
|
{entryCollections.map(c => (
|
||||||
<div key={c.collection_uid} className="coll-row">
|
<div key={c.collection_uid} className="rail-item">
|
||||||
<span className="coll-name">{c.collection_uid}</span>
|
<span className="rail-label">{c.collection_uid}</span>:{' '}
|
||||||
<span className="vis-badge">
|
<span className="muted">
|
||||||
{VIS_LABEL[c.visibility_bits] ?? `bits:${c.visibility_bits}`}
|
{{ 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }[c.visibility_bits] ?? `bits:${c.visibility_bits}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import EntryRow from './EntryRow';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: EntryRow,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const sampleEntry = {
|
|
||||||
entry_uid: 'entry_123',
|
|
||||||
title: 'A Great Article About Web Design',
|
|
||||||
archived_at: '2026-06-27T10:30:00Z',
|
|
||||||
entity_kind: 'page',
|
|
||||||
source_kind: 'web',
|
|
||||||
total_artifact_bytes: 2048576,
|
|
||||||
original_url: 'https://example.com/article',
|
|
||||||
has_favicon: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const videoEntry = {
|
|
||||||
entry_uid: 'entry_456',
|
|
||||||
title: 'React Performance Tips',
|
|
||||||
archived_at: '2026-06-26T14:15:00Z',
|
|
||||||
entity_kind: 'video',
|
|
||||||
source_kind: 'youtube',
|
|
||||||
total_artifact_bytes: 104857600,
|
|
||||||
original_url: 'https://youtube.com/watch?v=xyz',
|
|
||||||
has_favicon: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Default = {
|
|
||||||
args: {
|
|
||||||
entry: sampleEntry,
|
|
||||||
archiveId: 'archive_1',
|
|
||||||
isSelected: false,
|
|
||||||
onSelect: () => {},
|
|
||||||
},
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '178px 38% 130px 110px 34%',
|
|
||||||
gap: '10px',
|
|
||||||
padding: '10px',
|
|
||||||
background: 'var(--paper-3)',
|
|
||||||
}}>
|
|
||||||
<Story />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Selected = {
|
|
||||||
args: {
|
|
||||||
...Default.args,
|
|
||||||
isSelected: true,
|
|
||||||
},
|
|
||||||
decorators: Default.decorators,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Video = {
|
|
||||||
args: {
|
|
||||||
...Default.args,
|
|
||||||
entry: videoEntry,
|
|
||||||
},
|
|
||||||
decorators: Default.decorators,
|
|
||||||
};
|
|
||||||
|
|
@ -23,41 +23,32 @@ export default function LoginPage({ onLogin }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-page">
|
<div className="login-page">
|
||||||
<div className="login-card">
|
<h1>Archivr</h1>
|
||||||
<h1 className="login-brand">Archivr</h1>
|
|
||||||
<p className="login-tagline">Sign in to your archive</p>
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="login-field">
|
<label>
|
||||||
<label className="login-label" htmlFor="login-username">Username</label>
|
Username
|
||||||
<input
|
<input
|
||||||
className="login-input"
|
|
||||||
id="login-username"
|
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={e => setUsername(e.target.value)}
|
onChange={e => setUsername(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
autoComplete="username"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
<div className="login-field">
|
<label>
|
||||||
<label className="login-label" htmlFor="login-password">Password</label>
|
Password
|
||||||
<input
|
<input
|
||||||
className="login-input"
|
|
||||||
id="login-password"
|
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
{error && <p className="login-error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<button className="login-submit" type="submit" disabled={loading}>
|
<button type="submit" disabled={loading}>
|
||||||
{loading ? 'Signing in\u2026' : 'Sign in'}
|
{loading ? 'Logging in\u2026' : 'Log in'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,24 @@
|
||||||
function fmtDate(iso) {
|
|
||||||
if (!iso) return '—';
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
|
||||||
year: 'numeric', month: 'short', day: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }) {
|
|
||||||
const cls = status === 'completed' ? 'run-status--completed'
|
|
||||||
: status === 'failed' ? 'run-status--failed'
|
|
||||||
: status === 'running' ? 'run-status--running'
|
|
||||||
: '';
|
|
||||||
return <span className={`run-status ${cls}`}>{status || '—'}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RunsView({ runs }) {
|
export default function RunsView({ runs }) {
|
||||||
return (
|
return (
|
||||||
<section id="runs-view" className="view is-active">
|
<section id="runs-view" className="view is-active">
|
||||||
<table className="entry-table">
|
<table className="entry-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Started</th>
|
<th>Started</th><th>Status</th><th>Requested</th><th>Completed</th><th>Failed</th>
|
||||||
<th>Status</th>
|
|
||||||
<th>Requested</th>
|
|
||||||
<th>Completed</th>
|
|
||||||
<th>Failed</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{runs.length === 0 ? (
|
{runs.map((run, i) => (
|
||||||
<tr><td colSpan={5} style={{ color: 'var(--muted)', padding: '24px 16px', textAlign: 'center' }}>No runs yet.</td></tr>
|
|
||||||
) : runs.map((run, i) => (
|
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td>{fmtDate(run.started_at)}</td>
|
<td>{run.started_at ?? ''}</td>
|
||||||
<td><StatusBadge status={run.status} /></td>
|
<td>{run.status ?? ''}</td>
|
||||||
<td>{run.requested_count ?? '—'}</td>
|
<td>{run.requested_count ?? ''}</td>
|
||||||
<td>{run.completed_count ?? '—'}</td>
|
<td>{run.completed_count ?? ''}</td>
|
||||||
<td>{run.failed_count ?? '—'}</td>
|
<td>{run.failed_count ?? ''}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,24 +7,23 @@ import {
|
||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
|
|
||||||
const ROLE_ADMIN = 4
|
const ROLE_ADMIN = 4
|
||||||
|
const ROLE_OWNER = 8
|
||||||
|
|
||||||
export default function SettingsView() {
|
export default function SettingsView() {
|
||||||
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
|
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
|
||||||
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
|
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
|
||||||
const [tab, setTab] = useState('profile')
|
const [tab, setTab] = useState('profile')
|
||||||
|
|
||||||
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance'] : [])]
|
|
||||||
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance' }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="admin-view">
|
<section className="admin-view">
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
<div className="view-tabs">
|
<div className="admin-tabs" style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
||||||
{tabs.map(t => (
|
{['profile', 'tokens', ...(isAdmin ? ['instance'] : [])].map(t => (
|
||||||
<button key={t}
|
<button key={t}
|
||||||
className={`view-tab${tab === t ? ' is-active' : ''}`}
|
className={`nav-link${tab === t ? ' is-active' : ''}`}
|
||||||
|
style={{ borderBottom: tab === t ? '2px solid var(--accent)' : undefined, color: 'var(--ink)' }}
|
||||||
onClick={() => setTab(t)}>
|
onClick={() => setTab(t)}>
|
||||||
{tabLabels[t]}
|
{t === 'profile' ? 'Profile' : t === 'tokens' ? 'API Tokens' : 'Instance'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -79,43 +78,34 @@ function ProfileTab({ currentUser, setCurrentUser }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 440 }}>
|
<div style={{ maxWidth: 480 }}>
|
||||||
<div className="form-section">
|
<div className="admin-section">
|
||||||
<h2>Display Name</h2>
|
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Display Name</h2>
|
||||||
<form onSubmit={handleSaveProfile}>
|
<form className="admin-form" onSubmit={handleSaveProfile}>
|
||||||
<div className="form-field">
|
<input className="admin-input" placeholder={currentUser?.username ?? ''}
|
||||||
<label className="form-label" htmlFor="display-name">Name shown in the UI</label>
|
|
||||||
<input className="field-input" id="display-name"
|
|
||||||
placeholder={currentUser?.username ?? ''}
|
|
||||||
value={displayName} onChange={e => setDisplayName(e.target.value)} />
|
value={displayName} onChange={e => setDisplayName(e.target.value)} />
|
||||||
</div>
|
{saveMsg && (
|
||||||
{saveMsg && <div className={`form-msg form-msg--${saveMsg.ok ? 'ok' : 'err'}`}>{saveMsg.text}</div>}
|
<div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>
|
||||||
<button className="btn-primary" type="submit" disabled={saving}>
|
)}
|
||||||
|
<button className="capture-submit" type="submit" disabled={saving}>
|
||||||
{saving ? 'Saving\u2026' : 'Save'}
|
{saving ? 'Saving\u2026' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-section">
|
<div className="admin-section" style={{ marginTop: 28 }}>
|
||||||
<h2>Change Password</h2>
|
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Change Password</h2>
|
||||||
<form onSubmit={handleChangePassword}>
|
<form className="admin-form" onSubmit={handleChangePassword}>
|
||||||
<div className="form-field">
|
<input className="admin-input" type="password" placeholder="Current password"
|
||||||
<label className="form-label" htmlFor="cur-pw">Current password</label>
|
|
||||||
<input className="field-input" id="cur-pw" type="password"
|
|
||||||
value={curPw} onChange={e => setCurPw(e.target.value)} required />
|
value={curPw} onChange={e => setCurPw(e.target.value)} required />
|
||||||
</div>
|
<input className="admin-input" type="password" placeholder="New password"
|
||||||
<div className="form-field">
|
|
||||||
<label className="form-label" htmlFor="new-pw">New password</label>
|
|
||||||
<input className="field-input" id="new-pw" type="password"
|
|
||||||
value={newPw} onChange={e => setNewPw(e.target.value)} required />
|
value={newPw} onChange={e => setNewPw(e.target.value)} required />
|
||||||
</div>
|
<input className="admin-input" type="password" placeholder="Confirm new password"
|
||||||
<div className="form-field">
|
|
||||||
<label className="form-label" htmlFor="confirm-pw">Confirm new password</label>
|
|
||||||
<input className="field-input" id="confirm-pw" type="password"
|
|
||||||
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} required />
|
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} required />
|
||||||
</div>
|
{pwMsg && (
|
||||||
{pwMsg && <div className={`form-msg form-msg--${pwMsg.ok ? 'ok' : 'err'}`}>{pwMsg.text}</div>}
|
<div className={pwMsg.ok ? 'muted' : 'capture-error'}>{pwMsg.text}</div>
|
||||||
<button className="btn-primary" type="submit" disabled={pwSaving}>
|
)}
|
||||||
|
<button className="capture-submit" type="submit" disabled={pwSaving}>
|
||||||
{pwSaving ? 'Changing\u2026' : 'Change Password'}
|
{pwSaving ? 'Changing\u2026' : 'Change Password'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -130,7 +120,7 @@ function TokensTab() {
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [newToken, setNewToken] = useState(null)
|
const [newToken, setNewToken] = useState(null) // { raw_token, name }
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true); setError(null)
|
setLoading(true); setError(null)
|
||||||
|
|
@ -167,40 +157,42 @@ function TokensTab() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 600 }}>
|
<div style={{ maxWidth: 640 }}>
|
||||||
<div className="form-section">
|
<h2 style={{ fontSize: 15, marginBottom: 12 }}>API Tokens</h2>
|
||||||
<h2>API Tokens</h2>
|
|
||||||
{newToken && (
|
{newToken && (
|
||||||
<div className="token-banner">
|
<div style={{ background: '#e8f5e9', border: '1px solid #a5d6a7', padding: '12px 14px', marginBottom: 16, fontSize: 13 }}>
|
||||||
<strong>Token created.</strong> Copy it now — it won't be shown again.
|
<strong>Token created.</strong> Copy it now \u2014 it will not be shown again.<br />
|
||||||
<code>{newToken.raw_token}</code>
|
<code style={{ wordBreak: 'break-all', display: 'block', marginTop: 6, padding: '6px 8px', background: '#f5f5f5', border: '1px solid #ddd' }}>
|
||||||
<button className="token-dismiss" onClick={() => setNewToken(null)}>Dismiss</button>
|
{newToken.raw_token}
|
||||||
|
</code>
|
||||||
|
<button style={{ marginTop: 8, fontSize: 12, border: '1px solid #aaa', background: 'none', cursor: 'pointer' }}
|
||||||
|
onClick={() => setNewToken(null)}>Dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form className="token-create-row" onSubmit={handleCreate}>
|
<form className="admin-form" onSubmit={handleCreate} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
<input className="field-input field-input--flex" placeholder="Token name"
|
<input className="admin-input" placeholder="Token name" value={newName}
|
||||||
value={newName} onChange={e => setNewName(e.target.value)} required />
|
onChange={e => setNewName(e.target.value)} style={{ flex: 1 }} required />
|
||||||
<button className="btn-primary" type="submit" disabled={creating}>
|
<button className="capture-submit" type="submit" disabled={creating}>
|
||||||
{creating ? 'Creating\u2026' : 'Create token'}
|
{creating ? 'Creating\u2026' : 'Create'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{error && <div className="form-msg form-msg--err">{error}</div>}
|
{error && <div className="capture-error">{error}</div>}
|
||||||
{loading ? (
|
{loading ? <div className="muted">Loading\u2026</div> : (
|
||||||
<div className="muted">Loading\u2026</div>
|
<div className="admin-list">
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{tokens.length === 0 && <div className="muted">No tokens yet.</div>}
|
{tokens.length === 0 && <div className="muted">No tokens yet.</div>}
|
||||||
{tokens.map(tok => (
|
{tokens.map(tok => (
|
||||||
<div key={tok.token_uid} className="token-row">
|
<div key={tok.token_uid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
<div className="token-row-info">
|
border: '1px solid var(--line)', background: 'var(--paper-3)', padding: '10px 12px' }}>
|
||||||
<strong>{tok.name}</strong>
|
<div>
|
||||||
<div className="muted">
|
<strong style={{ fontSize: 14 }}>{tok.name}</strong>
|
||||||
|
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||||
Created {tok.created_at.slice(0, 10)}
|
Created {tok.created_at.slice(0, 10)}
|
||||||
{tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`}
|
{tok.last_used_at && ` \u00b7 Last used ${tok.last_used_at.slice(0, 10)}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-danger" style={{ fontSize: 12, padding: '4px 10px' }}
|
<button onClick={() => handleDelete(tok.token_uid)}
|
||||||
onClick={() => handleDelete(tok.token_uid)}>
|
style={{ border: '1px solid var(--line)', background: 'none', cursor: 'pointer',
|
||||||
|
color: 'var(--accent)', fontSize: 12, padding: '4px 10px' }}>
|
||||||
Revoke
|
Revoke
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -208,7 +200,6 @@ function TokensTab() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,40 +232,40 @@ function InstanceTab() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="muted">Loading\u2026</div>
|
if (loading) return <div className="muted">Loading\u2026</div>
|
||||||
if (error) return <div className="form-msg form-msg--err">{error}</div>
|
if (error) return <div className="capture-error">{error}</div>
|
||||||
if (!settings) return null
|
if (!settings) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 440 }}>
|
<div style={{ maxWidth: 480 }}>
|
||||||
<div className="form-section">
|
<h2 style={{ fontSize: 15, marginBottom: 12 }}>Instance Settings</h2>
|
||||||
<h2>Instance Settings</h2>
|
|
||||||
<form onSubmit={handleSave}>
|
<form onSubmit={handleSave}>
|
||||||
|
<div className="admin-section">
|
||||||
{[
|
{[
|
||||||
['public_index_enabled', 'Public index (unauthenticated browsing)'],
|
['public_index_enabled', 'Public index (unauthenticated browsing)'],
|
||||||
['public_entry_content_enabled', 'Public entry content'],
|
['public_entry_content_enabled', 'Public entry content'],
|
||||||
['open_registration_enabled', 'Open registration'],
|
['open_registration_enabled', 'Open registration'],
|
||||||
].map(([key, label]) => (
|
].map(([key, label]) => (
|
||||||
<label key={key} className="checkbox-row">
|
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, cursor: 'pointer' }}>
|
||||||
<input type="checkbox" checked={!!settings[key]}
|
<input type="checkbox" checked={!!settings[key]}
|
||||||
onChange={e => setSettings(s => ({ ...s, [key]: e.target.checked }))} />
|
onChange={e => setSettings(s => ({ ...s, [key]: e.target.checked }))} />
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
<div className="form-field" style={{ marginTop: 4 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<label className="form-label">Default entry visibility</label>
|
<label style={{ display: 'block', fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Default entry visibility</label>
|
||||||
<select className="field-input" value={settings.default_entry_visibility}
|
<select className="admin-input" value={settings.default_entry_visibility}
|
||||||
onChange={e => setSettings(s => ({ ...s, default_entry_visibility: Number(e.target.value) }))}>
|
onChange={e => setSettings(s => ({ ...s, default_entry_visibility: Number(e.target.value) }))}>
|
||||||
<option value={0}>Private</option>
|
<option value={0}>Private (0)</option>
|
||||||
<option value={2}>Unlisted</option>
|
<option value={2}>Unlisted (2)</option>
|
||||||
<option value={3}>Public</option>
|
<option value={3}>Public (3)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{saveMsg && <div className={`form-msg form-msg--${saveMsg.ok ? 'ok' : 'err'}`}>{saveMsg.text}</div>}
|
</div>
|
||||||
<button className="btn-primary" type="submit" disabled={saving}>
|
{saveMsg && <div className={saveMsg.ok ? 'muted' : 'capture-error'}>{saveMsg.text}</div>}
|
||||||
|
<button className="capture-submit" type="submit" disabled={saving}>
|
||||||
{saving ? 'Saving\u2026' : 'Save Settings'}
|
{saving ? 'Saving\u2026' : 'Save Settings'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,53 +32,42 @@ export default function SetupPage({ onComplete }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="setup-page">
|
<div className="setup-page">
|
||||||
<div className="setup-card">
|
<h1>Welcome to Archivr</h1>
|
||||||
<h1 className="setup-brand">Archivr</h1>
|
<p>Create your owner account to get started.</p>
|
||||||
<p className="setup-tagline">Create your owner account to get started.</p>
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="setup-field">
|
<label>
|
||||||
<label className="setup-label" htmlFor="setup-username">Username</label>
|
Username
|
||||||
<input
|
<input
|
||||||
className="setup-input"
|
|
||||||
id="setup-username"
|
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={e => setUsername(e.target.value)}
|
onChange={e => setUsername(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
autoComplete="username"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
<div className="setup-field">
|
<label>
|
||||||
<label className="setup-label" htmlFor="setup-password">Password</label>
|
Password
|
||||||
<input
|
<input
|
||||||
className="setup-input"
|
|
||||||
id="setup-password"
|
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
<div className="setup-field">
|
<label>
|
||||||
<label className="setup-label" htmlFor="setup-confirm">Confirm password</label>
|
Confirm password
|
||||||
<input
|
<input
|
||||||
className="setup-input"
|
|
||||||
id="setup-confirm"
|
|
||||||
type="password"
|
type="password"
|
||||||
value={confirm}
|
value={confirm}
|
||||||
onChange={e => setConfirm(e.target.value)}
|
onChange={e => setConfirm(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
{error && <p className="setup-error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<button className="setup-submit" type="submit" disabled={loading}>
|
<button type="submit" disabled={loading}>
|
||||||
{loading ? 'Creating account\u2026' : 'Create account'}
|
{loading ? 'Creating account\u2026' : 'Create account'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,8 @@ export default function TagsView({ tagNodes, tagFilter, onTagFilterSet, onViewCh
|
||||||
return (
|
return (
|
||||||
<section id="tags-view" className="view is-active">
|
<section id="tags-view" className="view is-active">
|
||||||
<div className="tag-tree">
|
<div className="tag-tree">
|
||||||
<div className="tag-tree-header">
|
|
||||||
<span className="tag-tree-title">Tags</span>
|
|
||||||
{tagFilter && (
|
|
||||||
<span className="tag-tree-active">Filtering: {tagFilter}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{tagNodes.length === 0 ? (
|
{tagNodes.length === 0 ? (
|
||||||
<p className="muted" style={{ padding: '8px 0' }}>No tags yet.</p>
|
<div>No tags yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="tag-tree-list">
|
<ul className="tag-tree-list">
|
||||||
{tagNodes.map(node => (
|
{tagNodes.map(node => (
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,10 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
|
||||||
return (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="brand">Archivr</div>
|
<div className="brand">Archivr</div>
|
||||||
<div className="switcher">
|
<select className="archive-switcher" aria-label="Select archive"
|
||||||
<select aria-label="Select archive"
|
|
||||||
value={archiveId ?? ''} onChange={e => onArchiveChange(e.target.value)}>
|
value={archiveId ?? ''} onChange={e => onArchiveChange(e.target.value)}>
|
||||||
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
<nav className="nav" aria-label="Primary">
|
<nav className="nav" aria-label="Primary">
|
||||||
{['archive', 'runs', 'admin', 'tags', 'collections', 'settings'].map(name => (
|
{['archive', 'runs', 'admin', 'tags', 'collections', 'settings'].map(name => (
|
||||||
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
|
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
|
||||||
|
|
@ -33,8 +31,9 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
|
||||||
<button className="capture-button" onClick={onCaptureClick}>+ Capture</button>
|
<button className="capture-button" onClick={onCaptureClick}>+ Capture</button>
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
<div className="user-menu">
|
<div className="user-menu">
|
||||||
<span className="user-name">{currentUser.display_name || currentUser.username}</span>
|
<span className="username">{currentUser.display_name || currentUser.username}</span>
|
||||||
<button className="logout-btn" onClick={handleLogout} disabled={loggingOut}>
|
<button className="nav-link" onClick={() => onViewChange('settings')} style={{ color: '#d7cdbf', fontSize: 13 }}>Settings</button>
|
||||||
|
<button onClick={handleLogout} disabled={loggingOut} className="logout-btn">
|
||||||
{loggingOut ? 'Logging out\u2026' : 'Log out'}
|
{loggingOut ? 'Logging out\u2026' : 'Log out'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import Topbar from './Topbar';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
component: Topbar,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultArchives = [
|
|
||||||
{ id: '1', label: 'Main Archive' },
|
|
||||||
{ id: '2', label: 'Research' },
|
|
||||||
{ id: '3', label: 'Screenshots' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Default = {
|
|
||||||
args: {
|
|
||||||
archives: defaultArchives,
|
|
||||||
archiveId: '1',
|
|
||||||
onArchiveChange: () => {},
|
|
||||||
view: 'archive',
|
|
||||||
onViewChange: () => {},
|
|
||||||
onCaptureClick: () => {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WithUser = {
|
|
||||||
args: {
|
|
||||||
...Default.args,
|
|
||||||
},
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<div style={{ background: 'var(--paper)' }}>
|
|
||||||
<Story />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
export default {
|
|
||||||
title: 'UI Patterns',
|
|
||||||
tags: ['autodocs'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Buttons = () => (
|
|
||||||
<div style={{ padding: '20px', display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
|
||||||
<button className="capture-button">+ Capture</button>
|
|
||||||
<button className="nav-link">Nav Link</button>
|
|
||||||
<button className="nav-link is-active">Nav Link (Active)</button>
|
|
||||||
<button className="assign-tag-btn">Add Tag</button>
|
|
||||||
<button className="assign-tag-btn" style={{ pointerEvents: 'none', opacity: 0.6 }}>Disabled</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const FormInputs = () => (
|
|
||||||
<div style={{ padding: '20px', maxWidth: '400px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--muted)', fontSize: '12px' }}>Search Input</label>
|
|
||||||
<input className="search-input" type="search" placeholder="Search archive..." />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--muted)', fontSize: '12px' }}>Capture Input</label>
|
|
||||||
<input className="capture-input" type="text" placeholder="tweet:1234567890 or https://..." />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--muted)', fontSize: '12px' }}>Tag Input</label>
|
|
||||||
<input className="assign-tag-input" type="text" placeholder="/science/cs" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={{ display: 'block', marginBottom: '6px', color: 'var(--muted)', fontSize: '12px' }}>Archive Switcher</label>
|
|
||||||
<select className="archive-switcher" style={{ width: '100%' }}>
|
|
||||||
<option>Main Archive</option>
|
|
||||||
<option>Research</option>
|
|
||||||
<option>Screenshots</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Pills = () => (
|
|
||||||
<div style={{ padding: '20px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
||||||
<span className="type-pill">page</span>
|
|
||||||
<span className="type-pill">video</span>
|
|
||||||
<span className="type-pill">tweet_thread</span>
|
|
||||||
<span className="type-pill">file</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const TagPills = () => (
|
|
||||||
<div style={{ padding: '20px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
||||||
<span className="tag-pill">
|
|
||||||
science
|
|
||||||
<button className="remove-tag">×</button>
|
|
||||||
</span>
|
|
||||||
<span className="tag-pill">
|
|
||||||
computer-science
|
|
||||||
<button className="remove-tag">×</button>
|
|
||||||
</span>
|
|
||||||
<span className="tag-pill">
|
|
||||||
learning
|
|
||||||
<button className="remove-tag">×</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ColorPalette = () => (
|
|
||||||
<div style={{ padding: '20px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ height: '80px', background: 'var(--ink)', marginBottom: '8px', borderRadius: '4px' }} />
|
|
||||||
<strong>Ink</strong> (#20251f)
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ height: '80px', background: 'var(--paper)', border: '1px solid var(--line)', marginBottom: '8px', borderRadius: '4px' }} />
|
|
||||||
<strong>Paper</strong> (#f5f0e7)
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ height: '80px', background: 'var(--accent)', marginBottom: '8px', borderRadius: '4px' }} />
|
|
||||||
<strong>Accent</strong> (#8d3f30)
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ height: '80px', background: 'var(--accent-2)', marginBottom: '8px', borderRadius: '4px' }} />
|
|
||||||
<strong>Accent 2</strong> (#b78342)
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ height: '80px', background: 'var(--link)', marginBottom: '8px', borderRadius: '4px' }} />
|
|
||||||
<strong>Link</strong> (#245f72)
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ height: '80px', background: 'var(--top)', marginBottom: '8px', borderRadius: '4px' }} />
|
|
||||||
<strong>Top</strong> (#141d18)
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ height: '80px', background: 'var(--muted)', marginBottom: '8px', borderRadius: '4px' }} />
|
|
||||||
<strong>Muted</strong> (#666a61)
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ height: '80px', background: 'var(--line)', marginBottom: '8px', borderRadius: '4px' }} />
|
|
||||||
<strong>Line</strong> (#d2c6b5)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,7 @@ export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
build: {
|
build: {
|
||||||
outDir: "../crates/archivr-server/static",
|
outDir: "../crates/archivr-server/static",
|
||||||
emptyOutDir: true,
|
emptyOutDir: false,
|
||||||
assetsDir: "assets",
|
assetsDir: "assets",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue