web ui redesign
This commit is contained in:
parent
6bbd874162
commit
594319a32d
19 changed files with 673 additions and 176 deletions
|
@ -210,7 +210,7 @@ const aclEvent = {
|
|||
Summary of core event types
|
||||
|
||||
- `x.user`: creates a user
|
||||
- `x.file`: creates a file
|
||||
- `x.file`: creates a file (also the only event able to reference blobs)
|
||||
- `x.redact`: removes another event
|
||||
- `x.acl`: TODO
|
||||
- `x.annotate`: TODO
|
||||
|
|
209
web/App.svelte
209
web/App.svelte
|
@ -5,27 +5,60 @@
|
|||
import Notes from "./scenes/Notes.svelte";
|
||||
import Forum from "./scenes/Forum";
|
||||
import Settings from "./scenes/Settings.svelte";
|
||||
import Popup from "./scenes/Files/Popup.svelte";
|
||||
import Audio from "./status/Audio.svelte";
|
||||
import Status from "./status/Status.svelte";
|
||||
import { api } from "./lib/api";
|
||||
import type { Event } from "./lib/api";
|
||||
import { events } from "./events";
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
interface Parts {
|
||||
sidebar: boolean,
|
||||
pins: boolean,
|
||||
statusMusic: Event | null,
|
||||
popup: Event | null,
|
||||
}
|
||||
|
||||
let options;
|
||||
let parts: Parts = {
|
||||
sidebar: true,
|
||||
pins: true,
|
||||
statusMusic: null,
|
||||
popup: null,
|
||||
};
|
||||
|
||||
let selected;
|
||||
let options;
|
||||
const items = [
|
||||
{ type: "home", id: "home", name: "home" },
|
||||
{ type: "files", id: "files", name: "files" },
|
||||
{ type: "links", id: "links", name: "links" },
|
||||
{ type: "notes", id: "notes", name: "notes" },
|
||||
{ type: "forum", id: "forum", name: "forum" },
|
||||
{ type: "settings", id: "settings", name: "settings" },
|
||||
];
|
||||
|
||||
function setScene() {
|
||||
function updateScene() {
|
||||
const url = new URL(location.hash.slice(1), location.origin);
|
||||
options = url.searchParams;
|
||||
selected = getScene(url.pathname);
|
||||
const idx = items.findIndex(i => i.id === url.pathname.slice(1));
|
||||
selected = items[idx === -1 ? 0 : idx];
|
||||
}
|
||||
|
||||
function getScene(name: string) {
|
||||
switch (name) {
|
||||
case "/files": return Files;
|
||||
case "/links": return Links;
|
||||
case "/notes": return Notes;
|
||||
case "/settings": return Settings;
|
||||
case "/forum": return Forum;
|
||||
default: return Home;
|
||||
function getComponent(type: string) {
|
||||
switch (type) {
|
||||
case "home": return Home;
|
||||
case "files": return Files;
|
||||
case "links": return Links;
|
||||
case "notes": return Notes;
|
||||
case "settings": return Settings;
|
||||
case "forum": return Forum;
|
||||
default: throw new Error("invalid/unsupported type");
|
||||
}
|
||||
}
|
||||
|
||||
updateScene();
|
||||
|
||||
// this always bothered me, but there was never a good way to sync animations... until now
|
||||
const syncedAnims = new Set(["hover"]);
|
||||
function syncAnimations(e: AnimationEvent) {
|
||||
|
@ -36,22 +69,148 @@
|
|||
}
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", setScene);
|
||||
window.addEventListener("load", setScene);
|
||||
function handleOpen(event: Event) {
|
||||
const mime = event.derived?.file?.mime ?? "application/octet-stream";
|
||||
console.log("handle open", event, mime);
|
||||
if (mime.startsWith("audio/")) {
|
||||
parts.statusMusic = event;
|
||||
} else {
|
||||
parts.popup = event;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose(thing: string) {
|
||||
console.log("close " + thing)
|
||||
switch (thing) {
|
||||
case "statusMusic": return parts.statusMusic = null;
|
||||
}
|
||||
}
|
||||
|
||||
events.on("open", handleOpen);
|
||||
events.on("close", handleClose);
|
||||
|
||||
onDestroy(() => {
|
||||
events.off("open", handleOpen);
|
||||
events.off("close", handleClose);
|
||||
});
|
||||
</script>
|
||||
<div id="wrapper">
|
||||
<header id="header">
|
||||
<nav>
|
||||
<a href="#/home">home</a>
|
||||
<a href="#/files">files</a>
|
||||
<a href="#/links">links</a>
|
||||
<a href="#/notes">notes</a>
|
||||
<a href="#/forum">forum</a>
|
||||
<a href="#/settings">settings</a>
|
||||
</nav>
|
||||
</header>
|
||||
<header id="header">header goes here</header>
|
||||
{#if parts.pins}
|
||||
<nav id="pins"></nav>
|
||||
{/if}
|
||||
<nav id="nav">
|
||||
{#each items as item (item.id)}
|
||||
<a class:selected={item.id === selected.id} href="#/{item.id}"><div>{item.name}</div></a>
|
||||
{/each}
|
||||
<a href="#" on:click|preventDefault={() => parts.sidebar = !parts.sidebar}><div>sidebar</div></a>
|
||||
<a href="#" on:click|preventDefault={() => parts.pins = !parts.pins}><div>pins</div></a>
|
||||
</nav>
|
||||
<main id="main">
|
||||
<svelte:component this={selected} {options} />
|
||||
<svelte:component this={getComponent(selected.type)} {options} />
|
||||
</main>
|
||||
{#if parts.sidebar}
|
||||
<aside id="side"></aside>
|
||||
{/if}
|
||||
<footer id="status">
|
||||
{#if parts.statusMusic}
|
||||
<div>
|
||||
<Audio event={parts.statusMusic} small={!parts.pins} />
|
||||
</div>
|
||||
{/if}
|
||||
<div><Status /></div>
|
||||
</footer>
|
||||
<header id="nav-header"></header>
|
||||
</div>
|
||||
<svelte:window on:animationstart={syncAnimations} />
|
||||
{#if parts.popup}
|
||||
<Popup event={parts.popup} close={() => parts.popup = null}/>
|
||||
{/if}
|
||||
<svelte:window on:animationstart={syncAnimations} on:hashchange={updateScene} />
|
||||
<style lang="scss">
|
||||
#wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: auto 48px 1fr 1fr auto;
|
||||
grid-template-columns: fit-content(64px) 240px 1fr auto;
|
||||
grid-template-areas:
|
||||
"pin notice notice notice"
|
||||
"pin nav-header header header"
|
||||
"pin nav main side"
|
||||
"pin nav main side"
|
||||
"status status main side";
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
gap: 1px;
|
||||
background: var(--borders);
|
||||
}
|
||||
|
||||
#pins {
|
||||
grid-area: pin;
|
||||
background: var(--bg-quartiary);
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
#nav {
|
||||
grid-area: nav;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
padding: 2px 0;
|
||||
|
||||
& > a {
|
||||
padding: 2px 4px;
|
||||
|
||||
& > div {
|
||||
padding: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus-visible) > div {
|
||||
background: #ffffff22;
|
||||
}
|
||||
|
||||
&.selected > div {
|
||||
background: #ffffff33;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#side {
|
||||
grid-area: side;
|
||||
background: var(--bg-secondary);
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
#status {
|
||||
grid-area: status;
|
||||
background: var(--bg-tertiary);
|
||||
width: 240px;
|
||||
|
||||
& > div + div {
|
||||
border-top: solid var(--borders) 1px;
|
||||
}
|
||||
}
|
||||
|
||||
#pins ~ #status {
|
||||
width: calc(64px + 240px);
|
||||
}
|
||||
|
||||
#nav-header {
|
||||
grid-area: nav-header;
|
||||
background: var(--bg-tertiary);
|
||||
// border-top-left-radius: 8px;
|
||||
}
|
||||
|
||||
#main {
|
||||
grid-area: main;
|
||||
background: var(--bg-primary);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#header {
|
||||
grid-area: header;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
38
web/Clock.svelte
Normal file
38
web/Clock.svelte
Normal file
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
export let speed = 1;
|
||||
let t = 0;
|
||||
$: minuteX = Math.cos(t * 6) * 10;
|
||||
$: minuteY = Math.sin(t * 6) * 10;
|
||||
$: hourX = Math.cos(t) * 6;
|
||||
$: hourY = Math.sin(t) * 6;
|
||||
|
||||
const interval = setInterval(() => t += .03 * speed, 1000 / 60);
|
||||
onDestroy(() => clearInterval(interval));
|
||||
</script>
|
||||
<svg viewBox="-2 -2 24 24">
|
||||
<path style="stroke: var(--color-accent)" d="
|
||||
M {hourX + 10} {hourY + 10}
|
||||
L 10,10
|
||||
L {minuteX + 10} {minuteY + 10}
|
||||
" />
|
||||
<path style="stroke: var(--fg-text)" d="
|
||||
M 0,10
|
||||
A 5 5 180 0 0 20 10
|
||||
A 5 5 180 0 0 0 10
|
||||
" />
|
||||
</svg>
|
||||
<style lang="scss">
|
||||
svg {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
& > path {
|
||||
fill: none;
|
||||
stroke: white;
|
||||
stroke-width: 2px;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
3
web/DESIGN.md
Normal file
3
web/DESIGN.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# design guidelines
|
||||
|
||||
tbd
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
@ -1,6 +1,6 @@
|
|||
<svelte:options immutable />
|
||||
<script lang="ts">
|
||||
import brokenImageUrl from "../broken.png";
|
||||
import brokenImageUrl from "../assets/broken.png";
|
||||
import { api } from "../lib/api";
|
||||
import type { Event } from "../lib/api";
|
||||
export let event: Event;
|
||||
|
|
10
web/events.ts
Normal file
10
web/events.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import EventEmitter from "events";
|
||||
import TypedEmitter from "typed-emitter";
|
||||
import type { Event } from "./lib/api";
|
||||
|
||||
type Events = {
|
||||
open: (event: Event) => void,
|
||||
close: (thing: "statusMusic") => void,
|
||||
}
|
||||
|
||||
export const events = new EventEmitter() as TypedEmitter<Events>
|
|
@ -14,6 +14,7 @@
|
|||
"dependencies": {
|
||||
"@noble/ed25519": "^2.0.0",
|
||||
"canonicalize": "^2.0.0",
|
||||
"carbon-icons-svelte": "^12.1.0",
|
||||
"events": "^3.3.0",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"uint8-to-base64": "^0.2.0"
|
||||
|
@ -21,6 +22,7 @@
|
|||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^2.4.2",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"sass": "^1.64.1",
|
||||
"svelte": "^4.0.3",
|
||||
"svelte-check": "^3.4.4",
|
||||
"tslib": "^2.6.0",
|
||||
|
|
|
@ -11,6 +11,9 @@ dependencies:
|
|||
canonicalize:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
carbon-icons-svelte:
|
||||
specifier: ^12.1.0
|
||||
version: 12.1.0
|
||||
events:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
|
@ -28,12 +31,15 @@ devDependencies:
|
|||
'@tsconfig/svelte':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
sass:
|
||||
specifier: ^1.64.1
|
||||
version: 1.64.1
|
||||
svelte:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
svelte-check:
|
||||
specifier: ^3.4.4
|
||||
version: 3.4.4(svelte@4.0.3)
|
||||
version: 3.4.4(sass@1.64.1)(svelte@4.0.3)
|
||||
tslib:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
|
@ -42,7 +48,7 @@ devDependencies:
|
|||
version: 5.1.6
|
||||
vite:
|
||||
specifier: ^4.3.9
|
||||
version: 4.3.9
|
||||
version: 4.3.9(sass@1.64.1)
|
||||
|
||||
packages:
|
||||
|
||||
|
@ -322,7 +328,7 @@ packages:
|
|||
'@sveltejs/vite-plugin-svelte': 2.4.2(svelte@4.0.3)(vite@4.3.9)
|
||||
debug: 4.3.4
|
||||
svelte: 4.0.3
|
||||
vite: 4.3.9
|
||||
vite: 4.3.9(sass@1.64.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
@ -341,7 +347,7 @@ packages:
|
|||
magic-string: 0.30.0
|
||||
svelte: 4.0.3
|
||||
svelte-hmr: 0.15.2(svelte@4.0.3)
|
||||
vite: 4.3.9
|
||||
vite: 4.3.9(sass@1.64.1)
|
||||
vitefu: 0.2.4(vite@4.3.9)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
@ -421,6 +427,10 @@ packages:
|
|||
resolution: {integrity: sha512-ulDEYPv7asdKvqahuAY35c1selLdzDwHqugK92hfkzvlDCwXRRelDkR+Er33md/PtnpqHemgkuDPanZ4fiYZ8w==}
|
||||
dev: false
|
||||
|
||||
/carbon-icons-svelte@12.1.0:
|
||||
resolution: {integrity: sha512-RPU+W/AhkdPRqHMaShOdFluQZXMT6PNYrqEwPxDBvmrX2ioOYhZo1J508e0WeVX69iOqyUv6KjxrdbsoDdogeg==}
|
||||
dev: false
|
||||
|
||||
/chokidar@3.5.3:
|
||||
resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
@ -588,6 +598,10 @@ packages:
|
|||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
dev: true
|
||||
|
||||
/immutable@4.3.1:
|
||||
resolution: {integrity: sha512-lj9cnmB/kVS0QHsJnYKD1uo3o39nrbKxszjnqS9Fr6NB7bZzW45U6WSGBPKXDL/CvDKqDNPA4r3DoDQ8GTxo2A==}
|
||||
dev: true
|
||||
|
||||
/import-fresh@3.3.0:
|
||||
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -829,6 +843,16 @@ packages:
|
|||
rimraf: 2.7.1
|
||||
dev: true
|
||||
|
||||
/sass@1.64.1:
|
||||
resolution: {integrity: sha512-16rRACSOFEE8VN7SCgBu1MpYCyN7urj9At898tyzdXFhC+a+yOX5dXwAR7L8/IdPJ1NB8OYoXmD55DM30B2kEQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
chokidar: 3.5.3
|
||||
immutable: 4.3.1
|
||||
source-map-js: 1.0.2
|
||||
dev: true
|
||||
|
||||
/sorcery@0.11.0:
|
||||
resolution: {integrity: sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==}
|
||||
hasBin: true
|
||||
|
@ -851,7 +875,7 @@ packages:
|
|||
min-indent: 1.0.1
|
||||
dev: true
|
||||
|
||||
/svelte-check@3.4.4(svelte@4.0.3):
|
||||
/svelte-check@3.4.4(sass@1.64.1)(svelte@4.0.3):
|
||||
resolution: {integrity: sha512-Uys9+R65cj8TmP8f5UpS7B2xKpNLYNxEWJsA5ZoKcWq/uwvABFF7xS6iPQGLoa7hxz0DS6xU60YFpmq06E4JxA==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
|
@ -864,7 +888,7 @@ packages:
|
|||
picocolors: 1.0.0
|
||||
sade: 1.8.1
|
||||
svelte: 4.0.3
|
||||
svelte-preprocess: 5.0.4(svelte@4.0.3)(typescript@5.1.6)
|
||||
svelte-preprocess: 5.0.4(sass@1.64.1)(svelte@4.0.3)(typescript@5.1.6)
|
||||
typescript: 5.1.6
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
@ -887,7 +911,7 @@ packages:
|
|||
svelte: 4.0.3
|
||||
dev: true
|
||||
|
||||
/svelte-preprocess@5.0.4(svelte@4.0.3)(typescript@5.1.6):
|
||||
/svelte-preprocess@5.0.4(sass@1.64.1)(svelte@4.0.3)(typescript@5.1.6):
|
||||
resolution: {integrity: sha512-ABia2QegosxOGsVlsSBJvoWeXy1wUKSfF7SWJdTjLAbx/Y3SrVevvvbFNQqrSJw89+lNSsM58SipmZJ5SRi5iw==}
|
||||
engines: {node: '>= 14.10.0'}
|
||||
requiresBuild: true
|
||||
|
@ -928,6 +952,7 @@ packages:
|
|||
'@types/pug': 2.0.6
|
||||
detect-indent: 6.1.0
|
||||
magic-string: 0.27.0
|
||||
sass: 1.64.1
|
||||
sorcery: 0.11.0
|
||||
strip-indent: 3.0.0
|
||||
svelte: 4.0.3
|
||||
|
@ -979,7 +1004,7 @@ packages:
|
|||
resolution: {integrity: sha512-r13jrghEYZAN99GeYpEjM107DOxqB65enskpwce8rRHVAGEtaWmsF5GqoGdPMf8DIXc9XyAJTdvlvRZi4LsszA==}
|
||||
dev: false
|
||||
|
||||
/vite@4.3.9:
|
||||
/vite@4.3.9(sass@1.64.1):
|
||||
resolution: {integrity: sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
hasBin: true
|
||||
|
@ -1007,6 +1032,7 @@ packages:
|
|||
esbuild: 0.17.19
|
||||
postcss: 8.4.24
|
||||
rollup: 3.26.0
|
||||
sass: 1.64.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
dev: true
|
||||
|
@ -1019,7 +1045,7 @@ packages:
|
|||
vite:
|
||||
optional: true
|
||||
dependencies:
|
||||
vite: 4.3.9
|
||||
vite: 4.3.9(sass@1.64.1)
|
||||
dev: true
|
||||
|
||||
/wrappy@1.0.2:
|
||||
|
|
|
@ -4,11 +4,9 @@
|
|||
import { onDestroy } from "svelte";
|
||||
import Gallery from "./Gallery.svelte";
|
||||
import List from "./List.svelte";
|
||||
import Popup from "./Popup.svelte";
|
||||
|
||||
export let options: URLSearchParams;
|
||||
let { events: items, stop } = loadImages(options);
|
||||
let popup = null;
|
||||
let tooltip = { x: 0, y: 0, text: null };
|
||||
|
||||
$: if (options) {
|
||||
|
@ -67,10 +65,6 @@
|
|||
tooltip.text = null;
|
||||
}
|
||||
|
||||
function showPopup(event) {
|
||||
popup = event;
|
||||
}
|
||||
|
||||
onDestroy(() => stop());
|
||||
|
||||
let fileTypes;
|
||||
|
@ -92,7 +86,7 @@
|
|||
|
||||
let viewAs = "gallery";
|
||||
</script>
|
||||
<div>
|
||||
<div class="wrapper">
|
||||
<!-- TODO: option to view as list instead of gallery -->
|
||||
<!-- TODO (qol+future): syntax highlighting for source file -->
|
||||
<div style="margin-bottom: 8px">
|
||||
|
@ -121,55 +115,13 @@
|
|||
<button on:click={() => viewAs = (viewAs === "gallery" ? "list" : "gallery")}>{viewAs}</button>
|
||||
</div>
|
||||
{#if viewAs === "gallery"}
|
||||
<Gallery items={filtered.map(i => i.event)} showPopup={(event) => popup = event} />
|
||||
<Gallery items={filtered.map(i => i.event)} />
|
||||
{:else if viewAs === "list"}
|
||||
<List items={filtered.map(i => i.event)} showPopup={(event) => popup = event} />
|
||||
{/if}
|
||||
{#if popup}
|
||||
<Popup event={popup} close={() => popup = null}/>
|
||||
<List items={filtered.map(i => i.event)} />
|
||||
{/if}
|
||||
</div>
|
||||
<style>
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
margin: 0 auto;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
background-color: #2a2a33;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.item .name {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
background-color: #000000aa;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: var(--bg-primary);
|
||||
border: solid var(--borders) 1px;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
pointer-events: none;
|
||||
animation: hover 1.5s ease-in-out infinite;
|
||||
transform-origin: top left;
|
||||
box-shadow: #00000055 4px 4px 4px;
|
||||
.wrapper {
|
||||
padding: 1em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import Img from "../../atoms/Img.svelte";
|
||||
import { events } from "../../events";
|
||||
|
||||
export let items;
|
||||
export let showPopup;
|
||||
|
||||
let tooltip = { x: 0, y: 0, text: null };
|
||||
|
||||
|
@ -27,7 +27,7 @@
|
|||
>
|
||||
{#each items as event (event.id)}
|
||||
{@const info = event.derived?.file ?? {}}
|
||||
<div class="item" on:click|preventDefault={() => showPopup(event)}>
|
||||
<div class="item" on:click|preventDefault={() => events.emit("open", event)}>
|
||||
<Img {event} thumb={[100, 100]} errorPlaceholder={/^(image|video)\//.test(info.mime)} />
|
||||
<div class="name">{event.content.name}</div>
|
||||
</div>
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { api } from "../../lib/api";
|
||||
import { timeAgo, formatSize } from "../../lib/util";
|
||||
import { events } from "../../events";
|
||||
|
||||
export let items;
|
||||
export let showPopup;
|
||||
|
||||
let tooltip = { x: 0, y: 0, text: null };
|
||||
|
||||
|
@ -32,7 +31,7 @@
|
|||
<tbody>
|
||||
{#each items as event (event.id)}
|
||||
<tr class="item">
|
||||
<td class="name"><a href="#" on:click|preventDefault={() => showPopup(event)}>{event.content.name}</a></td>
|
||||
<td class="name"><a href="#" on:click|preventDefault={() => events.emit("open", event)}>{event.content.name}</a></td>
|
||||
<td class="size">{formatSize(event.derived?.file?.size ?? 0)}</td>
|
||||
<td>{event.derived?.file?.mime}</td>
|
||||
<td>{timeAgo(event.origin_ts)}</td>
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
<h1>Hello there</h1>
|
||||
<p>Welcome to the web ui. keep in mind everything here is beta.</p>
|
||||
<p>If you have a private key and token, you can set it in <a href="#/settings">settings</a>.</p>
|
||||
<div class="wrapper">
|
||||
<h1>Hello there</h1>
|
||||
<p>Welcome to the web ui. keep in mind everything here is beta.</p>
|
||||
<p>If you have a private key and token, you can set it in <a href="#/settings">settings</a>.</p>
|
||||
</div>
|
||||
<style lang="scss">
|
||||
.wrapper {
|
||||
padding: 1em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -51,6 +51,7 @@
|
|||
ul {
|
||||
max-width: 850px;
|
||||
margin: 0 auto;
|
||||
padding: 1em;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
|
|
|
@ -83,6 +83,7 @@
|
|||
ul {
|
||||
max-width: 850px;
|
||||
margin: 0 auto;
|
||||
padding: 1em;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,31 +1,59 @@
|
|||
<script lang="ts">
|
||||
import { api, base64, ed25519 } from "../lib/api";
|
||||
</script>
|
||||
Server: <input
|
||||
type="text"
|
||||
bind:value={api.baseUrl}
|
||||
on:input={(e) => localStorage.setItem("server", e.target.value)}
|
||||
>
|
||||
<br />
|
||||
Token: <input
|
||||
type="text"
|
||||
bind:value={api.token}
|
||||
on:input={(e) => localStorage.setItem("token", e.target.value)}
|
||||
>
|
||||
<br />
|
||||
Secret key: <input
|
||||
type="text"
|
||||
value={ed25519.etc.bytesToHex(api.key)}
|
||||
on:input={(e) => {
|
||||
const newKey = e.target.value;
|
||||
if (newKey.length !== 64) return;
|
||||
api.key = ed25519.etc.hexToBytes(newKey);
|
||||
localStorage.setItem("key", newKey);
|
||||
}}
|
||||
>
|
||||
<br />
|
||||
{#await ed25519.getPublicKeyAsync(api.key)}
|
||||
User id: <input type="text" readonly value="loading...">
|
||||
{:then pubkey}
|
||||
User id: <input type="text" readonly value={"%" + base64.encode(pubkey)}>
|
||||
{/await}
|
||||
<table>
|
||||
<tr>
|
||||
<td>Server:</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={api.baseUrl}
|
||||
on:input={(e) => localStorage.setItem("server", e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Token: </td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={api.token}
|
||||
on:input={(e) => localStorage.setItem("token", e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Secret key:</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={ed25519.etc.bytesToHex(api.key)}
|
||||
on:input={(e) => {
|
||||
const newKey = e.target.value;
|
||||
if (newKey.length !== 64) return;
|
||||
api.key = ed25519.etc.hexToBytes(newKey);
|
||||
localStorage.setItem("key", newKey);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>User id:</td>
|
||||
<td>
|
||||
{#await ed25519.getPublicKeyAsync(api.key)}
|
||||
<input type="text" readonly value="loading..." />
|
||||
{:then pubkey}
|
||||
<input type="text" readonly value={"%" + base64.encode(pubkey)} />
|
||||
{/await}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<style lang="scss">
|
||||
table {
|
||||
padding: 1em;
|
||||
|
||||
& td:first-child {
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
280
web/status/Audio.svelte
Normal file
280
web/status/Audio.svelte
Normal file
|
@ -0,0 +1,280 @@
|
|||
<script lang="ts">
|
||||
import brokenImgUrl from "../assets/broken.png";
|
||||
import { api } from "../lib/api";
|
||||
import { events } from "../events";
|
||||
import Clock from "../Clock.svelte";
|
||||
import "carbon-icons-svelte"; // import everything so vite optimizes correctly
|
||||
import PlayIc from "carbon-icons-svelte/lib/PlayFilledAlt.svelte";
|
||||
import PauseIc from "carbon-icons-svelte/lib/PauseFilled.svelte";
|
||||
import RepeatIc from "carbon-icons-svelte/lib/Repeat.svelte";
|
||||
import VolumeDownIc from "carbon-icons-svelte/lib/VolumeDownFilled.svelte";
|
||||
import VolumeUpIc from "carbon-icons-svelte/lib/VolumeUpFilled.svelte";
|
||||
import VolumeMuteIc from "carbon-icons-svelte/lib/VolumeMuteFilled.svelte";
|
||||
import StopIc from "carbon-icons-svelte/lib/StopFilledAlt.svelte";
|
||||
export let event;
|
||||
export let small;
|
||||
$: url = api.getBlobUrl(event.id);
|
||||
$: media = event.derived?.media ?? {};
|
||||
let audio;
|
||||
let duration: number;
|
||||
let currentTime: number;
|
||||
let volume: number = 1;
|
||||
let muted: boolean;
|
||||
let paused: boolean;
|
||||
let loop: boolean;
|
||||
let loading = true;
|
||||
let loadingDebounce: number;
|
||||
|
||||
function quantize<T>(n: number, values: Array<T>): T {
|
||||
return values[Math.floor(n * values.length)] ?? values.at(-1);
|
||||
}
|
||||
|
||||
function fmtTime(time?: number): string {
|
||||
if (isNaN(time)) return "?:??";
|
||||
const seconds = Math.floor(time) % 60;
|
||||
const minutes = Math.floor((time / 60) % 60);
|
||||
const hours = Math.floor(time / (60 * 60));
|
||||
if (hours) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
} else {
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtArtistAlbum(media): string {
|
||||
const artist = media.artist;
|
||||
const album = media.album;
|
||||
if (!artist && !album) return "";
|
||||
if (artist && !album) return artist;
|
||||
if (!artist && album) return "in " + album;
|
||||
return `${artist} - ${album}`;
|
||||
}
|
||||
|
||||
function handleLoading(e) {
|
||||
clearTimeout(loadingDebounce);
|
||||
console.log(e.type);
|
||||
switch (e.type) {
|
||||
case "loadstart": {
|
||||
duration = null;
|
||||
// fallthrough;
|
||||
}
|
||||
case "stalled":
|
||||
case "waiting": {
|
||||
loadingDebounce = setTimeout(() => loading = true, 1);
|
||||
break;
|
||||
}
|
||||
case "playing": {
|
||||
loading = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let progressEl: HTMLDivElement;
|
||||
let previewTime: number | null = null;
|
||||
|
||||
function handleScrub(e) {
|
||||
const frac = e.layerX / progressEl.offsetWidth;
|
||||
previewTime = frac * duration;
|
||||
if (e.type === "mousedown" || e.buttons === 1) {
|
||||
currentTime = previewTime;
|
||||
}
|
||||
}
|
||||
|
||||
function endScrub() {
|
||||
previewTime = null;
|
||||
}
|
||||
|
||||
function scrubWheel(e: WheelEvent) {
|
||||
if (e.deltaY > 0) {
|
||||
currentTime = Math.max(currentTime - 5, 0);
|
||||
} else {
|
||||
currentTime = Math.min(currentTime + 5, duration);
|
||||
}
|
||||
}
|
||||
|
||||
function volumeWheel(e: WheelEvent) {
|
||||
if (e.deltaY > 0) {
|
||||
volume = Math.max(volume - .05, 0);
|
||||
} else {
|
||||
volume = Math.min(volume + .05, 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<div class="wrapper" class:small>
|
||||
<div
|
||||
class="progress"
|
||||
bind:this={progressEl}
|
||||
on:mousedown={handleScrub}
|
||||
on:mousemove={handleScrub}
|
||||
on:mouseout={endScrub}
|
||||
on:wheel={scrubWheel}
|
||||
>
|
||||
<div class="fill" style:width="{(100 * currentTime) / duration}%" />
|
||||
</div>
|
||||
<div class="thumb">
|
||||
{#if loading}<div class="load"><Clock /></div>{/if}
|
||||
<img
|
||||
src={api.getThumbUrl(event.id, 128, 128)}
|
||||
on:error={(e) => (e.target.src = brokenImgUrl)}
|
||||
/>
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="title">
|
||||
{media.title ?? event.content.name ?? "Unnamed Audio"}
|
||||
</div>
|
||||
<div class="misc" title={fmtArtistAlbum(media)}>
|
||||
{fmtArtistAlbum(media)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="time" on:wheel={scrubWheel}>
|
||||
<span class:preview={previewTime !== null}>
|
||||
{fmtTime(previewTime ?? currentTime ?? 0)}
|
||||
</span>
|
||||
/ {fmtTime(duration ?? event.derived?.file?.duration)}
|
||||
</div>
|
||||
<button on:click={() => (paused ? audio.play() : audio.pause())}>
|
||||
{#if paused}<PlayIc />{:else}<PauseIc />{/if}
|
||||
</button>
|
||||
<button on:click={() => loop = !loop}>
|
||||
<RepeatIc fill={loop ? "var(--color-accent)" : "var(--fg-text)"} />
|
||||
</button>
|
||||
<button on:click={() => (muted = !muted)} on:wheel={volumeWheel}>
|
||||
{#if muted}
|
||||
<VolumeMuteIc />
|
||||
{:else if volume <= 0.5}
|
||||
<VolumeDownIc />
|
||||
{:else}
|
||||
<VolumeUpIc />
|
||||
{/if}
|
||||
</button>
|
||||
<button on:click={() => events.emit("close", "statusMusic")}>
|
||||
<StopIc />
|
||||
</button>
|
||||
</div>
|
||||
<audio
|
||||
bind:this={audio}
|
||||
src={url}
|
||||
{loop}
|
||||
bind:currentTime
|
||||
bind:muted
|
||||
bind:volume
|
||||
bind:paused
|
||||
bind:duration
|
||||
autoplay
|
||||
on:waiting={handleLoading}
|
||||
on:playing={handleLoading}
|
||||
on:stalled={handleLoading}
|
||||
on:loadstart={handleLoading}
|
||||
on:ended={() => events.emit("close", "statusMusic")}
|
||||
/>
|
||||
</div>
|
||||
<style lang="scss">
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 64px auto;
|
||||
grid-template-rows: auto 36px 28px;
|
||||
grid-template-areas: "progress progress" "thumb info" "thumb controls";
|
||||
height: 100%;
|
||||
|
||||
&.small {
|
||||
grid-template-columns: 36px auto;
|
||||
grid-template-rows: auto 36px 28px;
|
||||
grid-template-areas: "progress progress" "thumb info" "controls controls";
|
||||
}
|
||||
|
||||
& > .progress {
|
||||
grid-area: progress;
|
||||
position: relative;
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
|
||||
& > .fill {
|
||||
background: var(--color-accent);
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&:hover > .fill {
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
& > .thumb {
|
||||
grid-area: thumb;
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
|
||||
& > img {
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
& > .load {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
& > .load + img {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
& > .info {
|
||||
grid-area: info;
|
||||
padding: 4px;
|
||||
color: var(--fg-text);
|
||||
|
||||
& > .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
& > .controls {
|
||||
grid-area: controls;
|
||||
display: flex;
|
||||
padding: 0 2px;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
gap: 2px;
|
||||
|
||||
& > .time {
|
||||
flex: 1;
|
||||
padding: 2px;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
|
||||
& > .preview {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
& > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
background: #cccccc11;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: #ffffff33;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
29
web/status/Status.svelte
Normal file
29
web/status/Status.svelte
Normal file
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { api, ed25519, base64 } from "../lib/api";
|
||||
</script>
|
||||
<div class="wrapper">
|
||||
stuff here soon...
|
||||
{#await ed25519.getPublicKeyAsync(api.key) then pubkey}
|
||||
<div class="userid">user id: <code>{"%" + base64.encode(pubkey)}</code></div>
|
||||
{/await}
|
||||
</div>
|
||||
<style lang="scss">
|
||||
.wrapper {
|
||||
min-height: 64px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.userid {
|
||||
font-size: .8rem;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
& > code {
|
||||
font-family: monospace;
|
||||
user-select: all;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -7,15 +7,20 @@
|
|||
|
||||
:root {
|
||||
--bg-primary: #1a1923;
|
||||
--bg-secondary: #131019;
|
||||
--bg-tertiary: #0a0e11;
|
||||
--fg-primary: #c7c6ca;
|
||||
--bg-secondary: #14131d;
|
||||
--bg-tertiary: #100e18;
|
||||
--bg-quartiary: #0a0e11;
|
||||
--never-see-this-color: #00ff1f;
|
||||
--fg-text: #c7c6ca;
|
||||
--fg-link: #b18cf3;
|
||||
--color-accent: #b18cf3;
|
||||
--borders: #222a30;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
color: var(--fg-text);
|
||||
font: 16px/1.3 sans-serif;
|
||||
}
|
||||
|
||||
h1 { font-size: 2em }
|
||||
|
@ -56,49 +61,6 @@ summary {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
#wrapper {
|
||||
display: grid;
|
||||
grid-template-areas: "header" "main";
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
color: var(--fg-primary);
|
||||
font: 16px/1.3 sans-serif;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
#main {
|
||||
background: var(--bg-primary);
|
||||
padding: 2em;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#header {
|
||||
grid-area: header;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: solid var(--borders) 1px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#header nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
#header nav a {
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
#header nav a:hover,
|
||||
#header nav a:focus-visible {
|
||||
background: #ffffff33;
|
||||
}
|
||||
|
||||
.description, .info {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
Loading…
Reference in a new issue