upend/webui/src/routes/+page.svelte

406 lines
8.6 KiB
Svelte

<script lang="ts">
import EntryList from '$lib/components/widgets/EntryList.svelte';
import EntityList from '$lib/components/widgets/EntityList.svelte';
import type { Widget } from '$lib/components/EntryView.svelte';
import EntryView from '$lib/components/EntryView.svelte';
import { Query, UpListing } from '@upnd/upend';
import { Any, Variable } from '@upnd/upend/query';
import UpObject from '$lib/components/display/UpObject.svelte';
import UpObjectCard from '$lib/components/display/UpObjectCard.svelte';
import Spinner from '$lib/components/utils/Spinner.svelte';
import api from '$lib/api';
import { query } from '$lib/entity';
import { vaultInfo } from '$lib/util/info';
import { updateTitle } from '$lib/util/title';
import { i18n } from '$lib/i18n';
import { ATTR_ADDED, ATTR_IN, ATTR_KEY, ATTR_LABEL, HIER_ROOT_ADDR } from '@upnd/upend/constants';
import UpLink from '$lib/components/display/UpLink.svelte';
import { goto } from '$app/navigation';
const roots = (async () => {
const data = await api.fetchRoots();
const listing = new UpListing(data);
return Object.values(listing.objects)
.filter((obj) => Boolean(obj.attr[ATTR_LABEL]))
.map((obj) => [obj.address, obj.identify().join(' | ')])
.sort(([_, i1], [__, i2]) => i1.localeCompare(i2));
})();
const keyed = (async () => {
const data = await api.query(Query.matches(Variable('keyed'), ATTR_KEY, Any));
return Object.values(data.objects)
.filter((obj) => !obj.get(ATTR_KEY)?.toString()?.startsWith('TYPE'))
.sort((a, b) =>
(a.get(ATTR_KEY)?.toString() || '').localeCompare(b.get(ATTR_KEY)?.toString() || '')
)
.map((obj) => obj.address);
})();
const groups = (async () => {
const data = await api.query(`(matches ? "${ATTR_IN}" ?)`);
const addresses = data.entries
.filter((e) => e.value.t === 'Address')
.map((e) => e.value.c) as string[];
const sortedAddresses = [...new Set(addresses)]
.map((address) => ({
address,
count: addresses.filter((a) => a === address).length
}))
.sort((a, b) => b.count - a.count);
const shortlist = sortedAddresses
.slice(0, 25)
.filter(({ address }) => address !== HIER_ROOT_ADDR);
const addressesString = shortlist.map(({ address }) => `@${address}`).join(' ');
const labels = (
await api.query(`(matches (in ${addressesString}) "${ATTR_LABEL}" ? )`)
).entries.filter((e) => e.value.t === 'String');
const display = shortlist.map(({ address, count }) => ({
address,
labels: labels
.filter((e) => e.entity === address)
.map((e) => e.value.c)
.sort() as string[],
count
}));
display
.sort((a, b) => (a.labels[0] || '').localeCompare(b.labels[0] || ''))
.sort((a, b) => b.count - a.count);
return { groups: display, total: sortedAddresses.length };
})();
const { result: recentQuery } = query(`(matches ? "LAST_VISITED" ?)`);
$: recent = ($recentQuery?.entries || [])
.filter((e) => e.value.t == 'Number')
.sort((a, b) => (b.value.c as number) - (a.value.c as number))
.slice(0, 25);
const { result: frequentQuery } = query(`(matches ? "NUM_VISITED" ?)`);
$: frequent = ($frequentQuery?.entries || [])
.filter((e) => e.value.t == 'Number')
.sort((a, b) => (b.value.c as number) - (a.value.c as number))
.slice(0, 25);
const { result: latestQuery } = query(`(matches ? "${ATTR_ADDED}" ?)`);
$: latest = ($latestQuery?.entries || [])
.filter((e) => e.value.t == 'Number')
.sort((a, b) => (b.value.c as number) - (a.value.c as number))
.slice(0, 25);
const shortWidgets: Widget[] = [
{
name: 'List',
icon: 'list-ul',
components: ({ entries }) => [
{
component: EntryList,
props: {
columns: 'value, entity',
columnWidths: ['6em'],
orderByValue: true,
header: false,
entries
}
}
]
},
{
name: 'EntityList',
icon: 'image',
components: ({ entries }) => [
{
component: EntityList,
props: {
entities: entries.map((e) => e.entity),
sort: false
}
}
]
}
];
const longWidgets: Widget[] = [
{
name: 'List',
icon: 'list-ul',
components: ({ entries }) => [
{
component: EntryList,
props: {
columns: 'value, entity',
columnWidths: ['13em'],
orderByValue: true,
header: false,
entries
}
}
]
},
{
name: 'EntityList',
icon: 'image',
components: ({ entries }) => [
{
component: EntityList,
props: {
entities: entries.map((e) => e.entity),
sort: false
}
}
]
}
];
$: updateTitle($vaultInfo?.name || $i18n.t('Home') || 'Home');
</script>
<div class="home">
<h1>
{$vaultInfo?.name || 'UpEnd'}
</h1>
<section class="roots">
<h2>
<UpLink text to={{ entity: HIER_ROOT_ADDR }}>{$i18n.t('Roots')}</UpLink>
</h2>
{#await roots}
<Spinner centered />
{:then data}
<ul>
{#each data as [address, _]}
<li class="root">
{#if data.length <= 2}
<UpObjectCard {address} />
{:else}
<UpObject banner link {address} />
{/if}
</li>
{:else}
<li>No roots.</li>
{/each}
</ul>
{/await}
</section>
{#await keyed then data}
{#if data}
<section class="keyed">
<h2>
<UpLink text to={{ attribute: ATTR_KEY }}>{$i18n.t('Keyed')}</UpLink>
</h2>
<ul>
{#each data as address}
<li class="root">
<UpObject banner={data.length <= 4} link {address} />
</li>
{/each}
</ul>
</section>
{/if}
{/await}
<section class="groups">
<h2>
<a href="/browse/groups">
{$i18n.t('Groups')}
</a>
</h2>
{#await groups}
<Spinner centered />
{:then data}
<ul>
{#each data.groups as group}
<li class="group">
<UpObject link address={group.address} labels={group.labels} />
<div class="count">{group.count}</div>
</li>
{:else}
<li>No groups?</li>
{/each}
{#if data.groups && data.total > data.groups.length}
<li>+ {data.total - data.groups.length}...</li>
{/if}
</ul>
{/await}
</section>
<div class="frecent">
{#if frequent.length || $frequentQuery === undefined}
<section class="frequent">
<h2>{$i18n.t('Frequently visited')}</h2>
{#if $frequentQuery == undefined}
<Spinner centered />
{:else}
<EntryView
--current-background="var(--background)"
entries={frequent}
widgets={shortWidgets}
/>
{/if}
</section>
{/if}
{#if recent.length || $recentQuery === undefined}
<section class="recent">
<h2>{$i18n.t('Recently visited')}</h2>
{#if $recentQuery == undefined}
<Spinner centered />
{:else}
<EntryView
--current-background="var(--background)"
entries={recent}
widgets={longWidgets}
/>
{/if}
</section>
{/if}
</div>
{#if latest.length || $latestQuery === undefined}
<section class="latest">
<h2>{$i18n.t('Most recently added')}</h2>
{#if $latestQuery == undefined}
<Spinner centered />
{:else}
<EntryView
--current-background="var(--background)"
entries={latest}
widgets={longWidgets}
/>
{/if}
</section>
{/if}
<div class="button store-button">
<a href="/store">
{$i18n.t('View store statistics')}
</a>
</div>
<footer>
<div>
<strong>UpEnd</strong> - {$i18n.t(
'a database for the complex, the changing, and the indeterminate'
)}
</div>
<div>
<a target="_blank" href="https://upend.dev" class="version">
{$vaultInfo?.version || '???'}
</a>
</div>
</footer>
</div>
<style lang="scss">
.home {
display: flex;
flex-direction: column;
}
h1,
h2 {
text-align: center;
}
h1 {
font-size: 32pt;
font-variant: small-caps;
margin: 2rem 0 0 0;
}
h2 {
margin: 2rem 0;
}
.latest,
.frecent {
margin: 0 2rem;
}
.frecent {
display: flex;
gap: 2rem;
& > * {
flex-basis: 50%;
}
@media screen and (max-width: 600px) {
flex-direction: column;
}
}
.roots,
.keyed {
ul {
list-style: none;
padding: 0;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
width: 80%;
gap: 1rem;
}
li {
flex-basis: calc(50% - 2rem / 2);
&:only-child {
flex-basis: 100%;
}
}
.root {
font-size: 24px;
}
}
.groups {
ul {
list-style: none;
padding: 0;
margin: 0 2rem;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
}
.group {
display: flex;
}
.count {
display: inline-block;
font-size: 0.66em;
margin-left: 0.25em;
}
}
footer {
border-top: 1px solid var(--foreground);
text-align: center;
margin: 1rem 2rem 2.5rem 2rem;
& > * {
margin: 0.5em;
}
}
.store-button {
display: inline-block;
padding: 1em;
margin: 2rem auto;
}
.version {
text-decoration: none;
opacity: 0.66;
}
</style>