refactors and a stupid animation
This commit is contained in:
parent
e1c243bbbc
commit
b2030047a3
12 changed files with 150 additions and 31 deletions
|
@ -22,7 +22,7 @@ struct Config {
|
|||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ConfigProfile {
|
||||
base_url: String, // TODO: use base_url in requests
|
||||
base_url: String,
|
||||
key: ActorSecret,
|
||||
token: String,
|
||||
}
|
||||
|
@ -101,7 +101,6 @@ async fn main() -> Result<(), Error> {
|
|||
Item::Blob(blob) => println!("blob ({} bytes)", blob.len()),
|
||||
};
|
||||
} else {
|
||||
// TODO: fix this mess
|
||||
let relations: Option<HashSet<_, _>> = rels
|
||||
.into_iter()
|
||||
.map(|i| i.split_once("/").map(|i| (i.0.to_string(), i.1.to_string())))
|
||||
|
|
|
@ -26,7 +26,13 @@ pub enum ActorIdParseError {
|
|||
#[derive(Debug, PartialEq, Eq, Clone, Error)]
|
||||
pub enum ActorSecretParseError {
|
||||
#[error("invalid base64")] InvalidBase64,
|
||||
#[error("incorrect byte count (should be 32)")] IncorrectByteCount,
|
||||
#[error("incorrect byte count (should be 64)")] IncorrectByteCount,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Error)]
|
||||
pub enum ActorSignatureParseError {
|
||||
#[error("invalid base64")] InvalidBase64,
|
||||
#[error("incorrect byte count (should be 64)")] IncorrectByteCount,
|
||||
}
|
||||
|
||||
impl ActorId {
|
||||
|
@ -177,17 +183,17 @@ impl<'de> Deserialize<'de> for ActorId {
|
|||
}
|
||||
|
||||
impl FromStr for ActorSignature {
|
||||
type Err = &'static str; // TODO: proper errors
|
||||
type Err = ActorSignatureParseError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let key = b64engine.decode(s).map_err(|_| "invalid base64")?;
|
||||
let bytes = key.try_into().map_err(|_| "incorrect byte count")?;
|
||||
let key = b64engine.decode(s).map_err(|_| ActorSignatureParseError::InvalidBase64)?;
|
||||
let bytes = key.try_into().map_err(|_| ActorSignatureParseError::IncorrectByteCount)?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for ActorSignature {
|
||||
type Error = &'static str;
|
||||
type Error = ActorSignatureParseError;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
Self::from_str(value)
|
||||
|
|
|
@ -17,4 +17,5 @@ pub trait Database {
|
|||
async fn create_event(&self, event: &Event) -> Result<(), Self::Error>;
|
||||
async fn get_event(&self, item_ref: &ItemRef) -> Result<Event, Self::Error>;
|
||||
async fn query_events(&self, query: &Query, limit: Option<u32>, after: Option<String>) -> Result<QueryResult, Self::Error>;
|
||||
// async fn query_relations(&self, query: &Vec<(String, String)>) -> Result<HashMap<ItemRef, Event>, Self::Error>;
|
||||
}
|
||||
|
|
|
@ -77,11 +77,9 @@ pub async fn route(
|
|||
};
|
||||
|
||||
// relations query
|
||||
dbg!(&query);
|
||||
let relations = if !query.relations.is_empty() && !result.events.is_empty() {
|
||||
// FIXME: events_to and events_from seem to be swapped???? i should really fix this
|
||||
let mut builder = sqlx::QueryBuilder::new("
|
||||
SELECT relations.ref_to AS item_ref, events_from.json AS json
|
||||
SELECT relations.ref_from AS item_ref, events_from.json AS json
|
||||
FROM relations
|
||||
JOIN events AS events_from ON events_from.ref = relations.ref_from
|
||||
JOIN events AS events_to ON events_to.ref = relations.ref_to
|
||||
|
|
|
@ -26,7 +26,7 @@ pub mod perms {
|
|||
impl AuthLevel for ReadOnly { fn new() -> Self { Self } fn to_num(&self) -> u8 { 1 } }
|
||||
impl AuthLevel for ReadWrite { fn new() -> Self { Self } fn to_num(&self) -> u8 { 2 } }
|
||||
impl AuthLevel for Everything { fn new() -> Self { Self } fn to_num(&self) -> u8 { 3 } }
|
||||
impl AuthLevel for Admin { fn new() -> Self { Self } fn to_num(&self) -> u8 { 4 } }
|
||||
impl AuthLevel for Admin { fn new() -> Self { Self } fn to_num(&self) -> u8 { 4 } }
|
||||
}
|
||||
|
||||
pub struct Authenticate<T: perms::AuthLevel> {
|
||||
|
|
|
@ -24,6 +24,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for (let anim of e.target.getAnimations()) {
|
||||
if (syncedAnims.has(anim.name)) {
|
||||
anim.startTime = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", setScene);
|
||||
window.addEventListener("load", setScene);
|
||||
</script>
|
||||
|
@ -41,3 +51,4 @@
|
|||
<svelte:component this={selected} {options} />
|
||||
</main>
|
||||
</div>
|
||||
<svelte:window on:animationstart={syncAnimations} />
|
||||
|
|
5
web/animations.css
Normal file
5
web/animations.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
@keyframes hover {
|
||||
0% { transform: translateY(-1px) }
|
||||
50% { transform: translateY(4px) }
|
||||
100% { transform: translateY(-1px) }
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
<svelte:options immutable />
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import brokenImageUrl from "./broken.png";
|
||||
import { api } from "./lib/api";
|
||||
import type { Event } from "./lib/api";
|
||||
import brokenImageUrl from "../broken.png";
|
||||
import { api } from "../lib/api";
|
||||
import type { Event } from "../lib/api";
|
||||
export let event: Event;
|
||||
export let style = "";
|
||||
export let thumb: null | [number, number] = null;
|
||||
|
@ -13,11 +13,10 @@
|
|||
onDestroy(() => url.then(url => URL.revokeObjectURL(url)));
|
||||
</script>
|
||||
{#await url}
|
||||
<img title="{event.content.name}" alt="{event.content.name}" {style} />
|
||||
<img alt="{event.content.name}" {style} />
|
||||
{:then url}
|
||||
<img
|
||||
src={url}
|
||||
title="{event.content.name}"
|
||||
alt="{event.content.name}"
|
||||
{style}
|
||||
on:error={e => e.target.src = brokenImageUrl}
|
||||
|
@ -25,6 +24,7 @@
|
|||
{/await}
|
||||
<style>
|
||||
img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
23
web/atoms/Text.svelte
Normal file
23
web/atoms/Text.svelte
Normal file
|
@ -0,0 +1,23 @@
|
|||
<svelte:options immutable />
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { api } from "../lib/api";
|
||||
import type { Event } from "../lib/api";
|
||||
export let event: Event;
|
||||
export let style = "";
|
||||
|
||||
let text = api.fetchBlob(event.id).then(b => b.text());
|
||||
let blob = text.then(t => new Blob([t], { type: "text/plain" }));
|
||||
export let url = blob.then(blob => URL.createObjectURL(blob));
|
||||
onDestroy(() => url.then(url => URL.revokeObjectURL(url)));
|
||||
</script>
|
||||
<pre>{#await text then content}{content}{/await}</pre>
|
||||
<style>
|
||||
img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
background: #2a2a33;
|
||||
}
|
||||
</style>
|
|
@ -5,6 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Thing</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="animations.css" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>this needs js</noscript>
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
<script lang="ts">
|
||||
import brokenImageUrl from "../broken.png";
|
||||
import { api } from "../lib/api";
|
||||
import { collectEvents } from "../lib/util";
|
||||
import { quadOut } from "svelte/easing";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import Img from "../Img.svelte";
|
||||
import Img from "../atoms/Img.svelte";
|
||||
import Text from "../atoms/Text.svelte";
|
||||
|
||||
export let options: URLSearchParams;
|
||||
let { events: items, stop } = loadImages(options);
|
||||
let popup = null;
|
||||
let galleryEl: HTMLDivElement;
|
||||
let tooltip = { x: 0, y: 0, text: null };
|
||||
|
||||
$: if (options) {
|
||||
stop();
|
||||
|
@ -66,12 +67,24 @@
|
|||
};
|
||||
}
|
||||
|
||||
function handleMouseover(e: MouseEvent) {
|
||||
tooltip = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: e.target.alt || e.target.dataset.fileName,
|
||||
};
|
||||
}
|
||||
|
||||
function handleMouseout(e: MouseEvent) {
|
||||
tooltip.text = null;
|
||||
}
|
||||
|
||||
onDestroy(() => stop());
|
||||
|
||||
let searchRaw = "";
|
||||
$: search = searchRaw.toLocaleLowerCase();
|
||||
$: mapped = $items.map(event => ({ event, info: event.derived.file ?? {} }));
|
||||
$: filtered = mapped.filter(({ info, event }) => info.mime.startsWith("image/") && event.content.name?.includes(search));
|
||||
$: filtered = mapped.filter(({ info, event }) => event.content.name?.includes(search));
|
||||
</script>
|
||||
<div>
|
||||
<div style="margin-bottom: 8px">
|
||||
|
@ -89,21 +102,48 @@
|
|||
-
|
||||
<input placeholder="Search filenames..." bind:value={searchRaw}>
|
||||
</div>
|
||||
<div class="gallery" bind:this={galleryEl}>
|
||||
<div
|
||||
class="gallery"
|
||||
bind:this={galleryEl}
|
||||
on:mouseover={handleMouseover}
|
||||
on:mousemove={handleMouseover}
|
||||
on:mouseout={handleMouseout}
|
||||
>
|
||||
{#each filtered as { event, info } (event.id)}
|
||||
<div class="item" on:click|preventDefault={() => popup = { event }}>
|
||||
{#if info.mime.startsWith("image/")}
|
||||
<div on:click|preventDefault={() => popup = { event }}>
|
||||
<Img {event} thumb={[100, 100]} />
|
||||
</div>
|
||||
<Img {event} thumb={[100, 100]} />
|
||||
{:else}
|
||||
<div style="padding: 4px; white-space: normal; height: 100%" data-file-name={event.content.name}>{event.content.name}</div>
|
||||
{/if}
|
||||
<div class="name">{event.content.name}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div
|
||||
class="tooltip"
|
||||
style:display={tooltip.text ? "block" : "none"}
|
||||
style:translate="{tooltip.x}px {tooltip.y}px"
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
{#if popup}
|
||||
<div class="popup" on:click={() => popup = null} transition:opacity>
|
||||
{@const event = popup.event}
|
||||
{@const info = event.derived.file ?? {}}
|
||||
<div class="popup" on:click={() => popup = null} transition:opacity data-event-id={event.id}>
|
||||
<div on:click|stopPropagation transition:pop role="dialog">
|
||||
<Img event={popup.event} style="max-width: 80vw; max-height: 80vh" bind:url={popup.url} />
|
||||
<div style="border-radius: 4px; overflow: hidden;">
|
||||
{#if info.mime.startsWith("image/")}
|
||||
<Img event={event} style="max-width: 80vw; max-height: 80vh" bind:url={popup.url} />
|
||||
{:else}
|
||||
<div class="text">
|
||||
<Text event={event} bind:url={popup.url} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#await popup.url then url}
|
||||
<a target="_blank" href={url}>Open in new tab</a> <span class="eventid">{popup.event.id}</span>
|
||||
<!-- <a target="_blank" href={url} download={event.content.name}>Open in new tab</a> -->
|
||||
<a target="_blank" href={url}>Open in new tab</a>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -114,23 +154,43 @@
|
|||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
margin: 0 auto;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gallery div {
|
||||
.item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
background-color: #2a2a33;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gallery img {
|
||||
.item .name {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 4px;
|
||||
background-color: #000000aa;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
background: #2a2a33;
|
||||
.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;
|
||||
}
|
||||
|
||||
.popup {
|
||||
|
@ -164,4 +224,15 @@
|
|||
.popup .eventid {
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.popup .text {
|
||||
background: #2a2a33;
|
||||
border-radius: 4px;
|
||||
white-space: normal;
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
font-family: monospace;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -14,6 +14,10 @@
|
|||
--borders: #222a30;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h1 { font-size: 2em }
|
||||
h2 { font-size: 1.5em }
|
||||
h3 { font-size: 1.3em }
|
||||
|
|
Loading…
Reference in a new issue