Compare commits

..

6 Commits

Author SHA1 Message Date
Tomáš Mládek 6ae20f9171 feat: add `user` to every Entry
ci/woodpecker/push/woodpecker Pipeline failed Details
(very ugly, lots of clones)
2024-04-02 21:08:39 +02:00
Tomáš Mládek 5771f32736 feat: add user management
- no more static keys, full register/login/logout flow
- add API error type
- refactor API to centralize request calls
- minor refactors re: vault options
- CSS refactor (buttons don't require classes, input styling)
2024-04-02 20:57:53 +02:00
Tomáš Mládek b480cf2e64 feat(backend): users with passwords 2024-04-02 20:57:53 +02:00
Tomáš Mládek 07c76423ac style(webui): contain COVERs in UpObject headers
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-04-02 20:53:56 +02:00
Tomáš Mládek 8932341445 fix(webui): action buttons no longer hidden on entries with long labels
ci/woodpecker/push/woodpecker Pipeline was successful Details
2024-04-02 16:49:04 +02:00
Tomáš Mládek 1f270d6dc7 feat(webui): quality of life improvements for upload dialog
ci/woodpecker/push/woodpecker Pipeline was successful Details
- when uploading, warn before closing tab
- allow cancelling in progress uploads
- when uploading multiple files, scroll to the current file
2024-04-01 21:17:44 +02:00
9 changed files with 121 additions and 52 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@upnd/upend",
"version": "0.4.1",
"version": "0.5.0",
"description": "Client library to interact with the UpEnd system.",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@ -121,6 +121,7 @@ export class UpEntry extends UpObject implements IEntry {
attribute: string;
value: IValue;
provenance: string;
user: string;
timestamp: string;
constructor(address: string, entry: IEntry, listing: UpListing) {
@ -130,6 +131,7 @@ export class UpEntry extends UpObject implements IEntry {
this.attribute = entry.attribute;
this.value = entry.value;
this.provenance = entry.provenance;
this.user = entry.user;
this.timestamp = entry.timestamp;
}

View File

@ -18,6 +18,8 @@ export interface IEntry {
value: IValue;
/** The origin or provenance of the data entry (e.g. SYSTEM or USER API...) */
provenance: string;
/** The user who created the data entry. */
user: string;
/** The timestamp when the data entry was created in RFC 3339 format. */
timestamp: string;
}

View File

@ -21,10 +21,13 @@
let files: File[] = [];
let URLs: string[] = [];
let uploading = false;
let abortController: AbortController | undefined;
let progress: Record<string, number> = {};
let totalProgress: number | undefined;
let filesElement: HTMLDivElement;
$: visible = files.length + URLs.length > 0;
addEmitter.on('files', (ev) => {
@ -40,9 +43,15 @@
uploading = true;
try {
abortController = new AbortController();
const addresses: string[] = [];
for (const file of files) {
for (const [idx, file] of files.entries()) {
filesElement
?.querySelectorAll('.entry')
[idx]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
const address = await api.putBlob(file, {
abortController,
onProgress: (p) => {
progress[file.name] = (p.loaded / p.total) * 100;
totalProgress = Object.values(progress).reduce((a, b) => a + b, 0) / files.length;
@ -50,6 +59,10 @@
timeout: -1
});
addresses.push(address);
if (!uploading) {
break;
}
}
if (addresses.length == 1) {
@ -67,11 +80,16 @@
}
function reset() {
if (!uploading) {
files = [];
URLs = [];
progress = {};
if (uploading) {
const msg = $i18n.t('Are you sure you want to cancel the upload?');
if (!confirm(msg)) return;
}
abortController?.abort();
files = [];
URLs = [];
progress = {};
uploading = false;
}
function onKeydown(event: KeyboardEvent) {
@ -84,14 +102,22 @@
reset();
}
}
function onBeforeUnload(ev: BeforeUnloadEvent) {
if (files.length || uploading) {
ev.preventDefault();
ev.returnValue = true;
}
}
</script>
<svelte:window on:beforeunload={onBeforeUnload} />
<svelte:body on:keydown={onKeydown} />
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="addmodal-container" class:visible class:uploading on:click={reset}>
<div class="addmodal" on:click|stopPropagation>
<div class="files">
<div class="files" bind:this={filesElement}>
{#each files as file}
<div class="entry">
<div class="row">

View File

@ -11,7 +11,6 @@
export let address: string | undefined = undefined;
export let index: number;
export let only: boolean;
export let background: string | undefined = undefined;
export let forceDetail = false;
let shifted = false;
let key = Math.random();
@ -64,35 +63,13 @@
window.addEventListener('mouseup', onMouseUp);
}
let resultBackground = background;
let imageBackground: string | undefined = undefined;
$: {
if (background?.startsWith('url(')) {
imageBackground = background;
resultBackground = 'transparent';
} else {
resultBackground = background;
imageBackground = undefined;
}
resultBackground ||= 'var(--background-lighter)';
}
function reload() {
key = Math.random();
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="browse-column"
class:detail
style="--background: {resultBackground}"
class:image-background={Boolean(imageBackground)}
on:mousemove={(ev) => (shifted = ev.shiftKey)}
>
{#if imageBackground}
<div class="background" style="background-image: {imageBackground}" />
{/if}
<div class="browse-column" class:detail on:mousemove={(ev) => (shifted = ev.shiftKey)}>
<div class="view" style="--width: {width}px">
<header>
{#if address}
@ -165,7 +142,7 @@
display: flex;
flex-direction: column;
background: var(--background);
background: var(--background-lighter);
color: var(--foreground-lighter);
border: 1px solid var(--foreground-lightest);
border-radius: 0.5em;

View File

@ -27,7 +27,6 @@
const dispatch = createEventDispatcher<{
resolved: string[];
close: void;
background: string | undefined;
}>();
export let address: string;
@ -393,19 +392,6 @@
);
}
});
$: {
const cover = $entity?.attr['COVER']?.[0];
if (!cover) {
dispatch('background', undefined);
} else {
switch (cover.value.t) {
case 'Address':
dispatch('background', `url('${api.getRaw(cover.value.c)}')`);
break;
}
}
}
</script>
<div
@ -527,13 +513,13 @@
<h2>{$i18n.t('Attributes')}</h2>
<EntryList
entries={$entity?.attributes || []}
columns={detail ? 'timestamp, provenance, attribute, value' : 'attribute, value'}
columns={detail ? 'timestamp, user, provenance, attribute, value' : 'attribute, value'}
on:change={onChange}
/>
<h2>{$i18n.t('Backlinks')}</h2>
<EntryList
entries={$entity?.backlinks || []}
columns={detail ? 'timestamp, provenance, entity, attribute' : 'entity, attribute'}
columns={detail ? 'timestamp, user, provenance, entity, attribute' : 'entity, attribute'}
on:change={onChange}
/>
</div>

View File

@ -152,6 +152,22 @@
dispatch('change', { type: 'upsert', attribute: ATTR_LABEL, value: ev.detail });
}
}
let background: string | undefined;
$: background = $entity?.get('COVER')?.toString();
let resultBackground = background;
let imageBackground: string | undefined = undefined;
$: {
if (background) {
imageBackground = `url(${api.getRaw(background)})`;
resultBackground = 'transparent';
} else {
resultBackground = background;
imageBackground = undefined;
}
resultBackground ||= 'var(--background-lighter)';
}
</script>
<div
@ -160,6 +176,8 @@
class:right-active={address == $addresses[$index + 1]}
class:selected={select && $selected.includes(address)}
class:plain
style="--background: {resultBackground}"
class:image-background={Boolean(imageBackground)}
>
<div
class="address"
@ -167,6 +185,10 @@
class:banner
class:show-type={$entityInfo?.t === 'Url' && !addressIds.length}
>
{#if imageBackground}
<div class="image-gradient"></div>
<div class="image-background" style="background-image: {imageBackground}"></div>
{/if}
<HashBadge {address} />
<div class="label" class:resolving title={displayLabel}>
<Editable
@ -264,6 +286,7 @@
}
.address {
position: relative;
flex-grow: 1;
min-width: 0;
@ -275,7 +298,7 @@
font-family: var(--monospace-font);
line-break: anywhere;
background: var(--background-lighter);
background: var(--background);
border: 0.1em solid var(--foreground-lighter);
border-radius: 0.2em;
@ -336,6 +359,29 @@
&.banner .secondary {
display: unset;
}
.image-gradient {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.66) 16%, var(--background) 66%);
z-index: -1;
}
.image-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -2;
background-size: cover;
background-position: center;
filter: brightness(0.8);
}
}
.label {

View File

@ -166,7 +166,12 @@
}}
/>
<div class="icon">
<IconButton name="trash" color="#dc322f" on:click={() => removeEntity(entity)} />
<IconButton
plain
name="trash"
color="#dc322f"
on:click={() => removeEntity(entity)}
/>
</div>
{:else}
<div class="object">
@ -181,7 +186,12 @@
/>
</div>
<div class="icon">
<IconButton name="trash" color="#dc322f" on:click={() => removeEntity(entity)} />
<IconButton
plain
name="trash"
color="#dc322f"
on:click={() => removeEntity(entity)}
/>
</div>
{/if}
{:else}
@ -262,7 +272,9 @@
.item {
display: flex;
.object {
width: 100%;
flex-grow: 1;
max-width: 100%;
min-width: 0;
}
.icon {
@ -298,6 +310,12 @@
}
}
.icon {
display: flex;
align-items: center;
margin-left: 0.25em;
}
.add {
display: flex;
flex-direction: column;

View File

@ -34,6 +34,7 @@
const TIMESTAMP_COL = 'timestamp';
const PROVENANCE_COL = 'provenance';
const USER_COL = 'user';
const ENTITY_COL = 'entity';
const ATTR_COL = 'attribute';
const VALUE_COL = 'value';
@ -188,6 +189,7 @@
const COLUMN_LABELS: { [key: string]: string } = {
timestamp: $i18n.t('Added at'),
provenance: $i18n.t('Provenance'),
user: $i18n.t('User'),
entity: $i18n.t('Entity'),
attribute: $i18n.t('Attribute'),
value: $i18n.t('Value')
@ -243,6 +245,16 @@
</div>
{:else if column == PROVENANCE_COL}
<div class="cell">{entry.provenance}</div>
{:else if column == USER_COL}
<div class="cell">
{#if entry.user}
{entry.user}
{:else}
<div class="unset">
{$i18n.t('unset')}
</div>
{/if}
</div>
{:else if column == ENTITY_COL}
<div class="cell entity mark-entity">
<UpObject