1
0
Fork 0

Switch gallery layout engine into a single function

main
Ambrose Chua 2020-10-11 22:21:13 +08:00
parent 0e54187b15
commit ff4646598f
15 changed files with 712 additions and 228 deletions

View File

@ -55,7 +55,7 @@ func sign(w http.ResponseWriter, req *http.Request) {
bucket := lib.Bucket(req.FormValue("bucket"))
token := req.FormValue("token")
request := SafePathable(req.FormValue("request"))
resource := SafePathable(req.FormValue("resource"))
err = bucket.Validate()
if err != nil {
@ -63,7 +63,7 @@ func sign(w http.ResponseWriter, req *http.Request) {
httphelpers.ErrorResponse(w, err)
return
}
err = request.Validate()
err = resource.Validate()
if err != nil {
err := fmt.Errorf("%w: %v", httphelpers.ErrorBadRequest, err.Error())
httphelpers.ErrorResponse(w, err)
@ -81,7 +81,7 @@ func sign(w http.ResponseWriter, req *http.Request) {
}
var signedReq *http.Request
unsignedReq, err := http.NewRequest(req.Method, bucket.URL(request).String(), nil)
unsignedReq, err := http.NewRequest(req.Method, bucket.URL(resource).String(), nil)
if err != nil {
err := fmt.Errorf("%w: error creating request: %v", httphelpers.ErrorBadRequest, err.Error())
httphelpers.ErrorResponse(w, err)
@ -91,13 +91,14 @@ func sign(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet {
signedReq = sig.PreSignRead(unsignedReq, cred)
} else if req.Method == http.MethodPost || req.Method == http.MethodPut || req.Method == http.MethodDelete {
signedReq = sig.PreSignRead(unsignedReq, cred)
signedReq = sig.PreSignWrite(unsignedReq, cred)
} else {
err := fmt.Errorf("%w: %v", httphelpers.ErrorMethodNotAllowed, req.Method)
httphelpers.ErrorResponse(w, err)
return
}
w.Header().Add("Access-Control-Allow-Origin", "*")
w.Header().Add("Location", signedReq.URL.String())
w.WriteHeader(http.StatusTemporaryRedirect)
}

View File

@ -17,11 +17,11 @@ func (s SafePathable) Validate() error {
if strings.HasPrefix(string(s), "internal/") {
return ErrorInvalidRequest
}
return nil
_, err := url.Parse(string(s))
return err
}
func (s SafePathable) Path() *url.URL {
return &url.URL{
Path: string(s),
}
u, _ := url.Parse(string(s))
return u
}

File diff suppressed because one or more lines are too long

View File

@ -53,7 +53,7 @@ dev: $(SOURCE)/* $(OUTPUT)/index.html $(shared_copy_files) | container
$(OUTPUT)/index.html: index.tmpl indextmpl.go
mkdir -p $(@D)
$(GO) run indextmpl.go -t $< -o $@ -a $(subst $(OUTPUT)/,,$(SHARED_COPY))/ -c $(CONTROL_ENDPOINT) -b $(DEV_BUCKET) -t "[dev] Manage"
$(GO) run indextmpl.go -t $< -o $@ -a $(subst $(OUTPUT)/,,$(SHARED_COPY))/ -c $(CONTROL_ENDPOINT) -b $(DEV_BUCKET) -title "[dev] Manage"
.SECONDEXPANSION:
$(shared_copy_files): $$(subst $$(SHARED_COPY)/,$$(SHARED)/,$$@)

View File

@ -9,6 +9,6 @@
<!-- Svelte! -->
<script src="{{if .Development}}{{else}}{{ .Assets }}js/{{end}}manage.js" defer async></script>
</head>
<body data-bucket="{{ .Bucket }}">
<body data-bucket="{{ .Bucket }}" data-control="{{ .Control }}">
</body>
</html>

View File

@ -30,7 +30,7 @@ func main() {
flag.StringVar(&data.Assets, "a", "", ".Assets")
flag.StringVar(&data.Control, "c", "", ".Control")
flag.StringVar(&data.Bucket, "b", "", ".Bucket")
flag.StringVar(&data.Title, "t", "", ".Title")
flag.StringVar(&data.Title, "title", "", ".Title")
flag.Parse()
f, err := os.Open(tmpl)

View File

@ -1,24 +1,59 @@
<script>
export let bucket;
import { fetcher, request, listPhotos } from './http.js';
import Gallery from './Gallery.svelte';
let photos = [
"photo/Screenshot from 2020-04-23 19-27-53.png",
"photo/Screenshot_2020-04-13 Looking Glass - Hurricane Electric (AS6939).png",
];
async function getMetadataTitle() {
const resp = await fetcher(request('GET', 'metadata/title'));
return resp.text();
}
let title = getMetadataTitle();
async function getPhotos() {
const resp = await fetcher(request('GET', listPhotos(10000)));
const text = await resp.text();
const xml = new window.DOMParser().parseFromString(text, "text/xml");
const contents = xml.querySelectorAll('ListBucketResult > Contents');
const photos = [];
contents.forEach(c => {
photos.push(c.querySelector('Key').innerHTML);
});
return photos;
}
let photos = getPhotos();
</script>
<svelte:head>
{#await title then title}
<title>{title}</title>
{:catch error}
<title>Error</title>
{/await}
</svelte:head>
<div>
<nav>
<ul>
<li><a href="..">Gallery</a></li>
</ul>
</nav>
<header>
<h1>{bucket}</h1>
{#await title}
<h1 class="dim">Loading...</h1>
{:then title}
<h1>{title}</h1>
{:catch error}
<h1 class="dim" title="{error}">Error getting title</h1>
{/await}
</header>
<Gallery {photos} />
{#await photos then photos}
<Gallery {photos} />
{:catch error}
<main>Unable to load photos: {error}</main>
{/await}
</div>
<!-- vim: set ft=html: -->

View File

@ -1,12 +1,21 @@
<script>
import { onMount, afterUpdate } from 'svelte';
import Gallery from '../build/shared/js/gallery.js';
import Photo from './Photo.svelte';
export let photos;
import Photo from './Photo.svelte';
let galleryEle;
let gallery;
onMount(() => {
gallery = new Gallery(galleryEle);
});
</script>
<main>
<main class="gallery" bind:this={galleryEle}>
{#each photos as photo}
<Photo {photo} />
<Photo {photo} on:sizechange={gallery.recompute()} />
{/each}
</main>

View File

@ -1,38 +1,97 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { fetcher, request } from './http.js';
const dispatch = createEventDispatcher();
export let photo;
$: raw = request('GET', photo).url;
async function ar() {
let title = '';
}
async function preview() {
let size = { width: 1, height: 1 };
$: ar = size.width / size.height;
}
let tags = [];
async function getPhotoMetadataTitle() {
const objectBase = photo.replace('photo/', 'photometadata/');
const resp = await fetcher(request('GET', objectBase + '/title'));
return resp.text();
}
onMount(async () => {
try {
title = await getPhotoMetadataTitle();
} catch (e) {
// We can ignore missing titles
}
});
async function getPhotoMetadataSize() {
const objectBase = photo.replace('photo/', 'photometadata/');
const resp = await fetcher(request('GET', objectBase + '/size'));
return resp.json();
}
onMount(async () => {
try {
size = await getPhotoMetadataSize();
dispatch('sizechange');
} catch (e) {
// TODO: Emit error
}
});
async function getPhotoMetadataTags() {
const objectBase = photo.replace('photo/', 'photometadata/');
const resp = await fetcher(request('GET', objectBase + '/tags'));
const str = await resp.text();
return str.split(",");
}
onMount(async () => {
try {
tags = await getPhotoMetadataTags();
} catch (e) {
// We can ignore missing tags
}
});
function preview(height, format, quality) {
const objectBase = photo.replace('photo/', 'preview/');
const extIndex = objectBase.lastIndexOf(".");
const base = objectBase.substring(0, extIndex);
const res = base + `_h${height}q${quality}`;
const ext = extensionByType(format)
return request('GET', res + ext).url;
}
function extensionByType(format) {
return {
"image/webp": ".webp",
"image/jpeg": ".jpg",
}[format];
}
</script>
<div class="gallery-item preloaded-thumbnail" data-ar="{ar(photo)}">
<a href="{photo}" data-enlarge="{photo}">
<div class="gallery-item preloaded-thumbnail" title="{title}" data-ar="{ar}">
<picture style="display: none;">
<source srcset="{preview(photo, 320, 'image/webp', 70)} 2x, {preview(photo, 160, 'image/webp', 70)} 1x" type="image/webp" media="(max-width: 900px)">
<source srcset="{preview(photo, 640, 'image/webp', 70)} 2x, {preview(photo, 320, 'image/webp', 70)} 1x" type="image/webp">
<source srcset="{preview(photo, 320, 'image/jpeg', 70)} 2x, {preview(photo, 160, 'image/jpeg', 70)} 1x" media="(max-width: 900px)">
<source srcset="{preview(photo, 640, 'image/jpeg', 70)} 2x, {preview(photo, 320, 'image/jpeg', 70)} 1x">
<img src="{preview(photo, 320, 'image/jpeg', 70)}"
<source srcset="{preview(320, 'image/webp', 70)} 2x, {preview(160, 'image/webp', 70)} 1x" type="image/webp" media="(max-width: 900px)">
<source srcset="{preview(640, 'image/webp', 70)} 2x, {preview(320, 'image/webp', 70)} 1x" type="image/webp">
<source srcset="{preview(320, 'image/jpeg', 70)} 2x, {preview(160, 'image/jpeg', 70)} 1x" media="(max-width: 900px)">
<source srcset="{preview(640, 'image/jpeg', 70)} 2x, {preview(320, 'image/jpeg', 70)} 1x">
<img src="{preview(320, 'image/jpeg', 70)}"
alt=""
width="{ar(photo) * 320}"
width="{ar * 320}"
height="320"
loading="lazy"
data-enlarge-preload-for="{photo}">
</picture>
<picture class="preloaded-thumbnail-image">
<source srcset="{preview(photo, 30, 'image/webp', 34)} 1x" type="image/webp" media="(max-width: 900px)">
<source srcset="{preview(photo, 60, 'image/webp', 34)} 1x" type="image/webp">
<source srcset="{preview(photo, 30, 'image/jpeg', 34)} 1x" media="(max-width: 900px)">
<source srcset="{preview(photo, 60, 'image/jpeg', 34)} 1x">
<img src="{preview(photo, 30, 'image/jpeg', 34)}"
<source srcset="{preview(30, 'image/webp', 34)} 1x" type="image/webp" media="(max-width: 900px)">
<source srcset="{preview(60, 'image/webp', 34)} 1x" type="image/webp">
<source srcset="{preview(30, 'image/jpeg', 34)} 1x" media="(max-width: 900px)">
<source srcset="{preview(60, 'image/jpeg', 34)} 1x">
<img src="{preview(30, 'image/jpeg', 34)}"
alt=""
width="{ar(photo) * 320}"
width="{ar * 320}"
height="320">
</picture>
</a>
</div>
<!-- vim: set ft=html: -->

54
web/manage/src/http.js Normal file
View File

@ -0,0 +1,54 @@
import { control, bucket, token } from './stores.js';
let control_value;
control.subscribe(value => {
control_value = value;
});
let bucket_value;
bucket.subscribe(value => {
bucket_value = value;
});
let token_value;
token.subscribe(value => {
token_value = value;
});
class RequestError extends Error {
constructor(res) {
super(`Request returned status ${res.status} ${res.statusText}`);
this.res = res;
}
}
export async function fetcher(request) {
const resp = await fetch(request);
if (resp.status != 200) {
throw new RequestError(resp);
}
return resp;
}
export function request(method, resource, body=null) {
const sign = new URL(control_value + '/sign');
sign.searchParams.append('bucket', bucket_value);
sign.searchParams.append('token', token_value);
sign.searchParams.append('resource', resource);
const req = new Request(sign.href, {
method,
body,
});
return req;
}
export function listPhotos(maxKeys=1000, startAfter='') {
const prefix = "photo/";
const params = new URLSearchParams();
params.set("list-type", "2");
params.set("metadata", "true");
params.set("encoding-type", "url");
params.set("prefix", prefix);
params.set("delimiter", "");
params.set("max-keys", maxKeys);
params.set("start-after", startAfter);
return '?' + params.toString();
}

View File

@ -2,9 +2,6 @@ import App from './App.svelte';
const app = new App({
target: document.body,
props: {
bucket: document.body.dataset.bucket,
}
});
export default app;

6
web/manage/src/stores.js Normal file
View File

@ -0,0 +1,6 @@
import { readable, writable } from 'svelte/store';
export const control = readable(document.body.dataset.control);
export const bucket = readable(document.body.dataset.bucket);
export const token = writable('todo');

View File

@ -48,6 +48,10 @@ h4, h5, h6 {
font-size: 1.272em;
}
.dim {
opacity: 0.333;
}
/*
* Molecules
*/

View File

@ -1,48 +1,90 @@
class LayoutEngine {
/*
rects = [];
export default class Gallery {
constructor(ele) {
this.ele = ele;
gap = 12;
baseHeight = 100;
// maxHeight = 320;
viewportWidth = 1024;
*/
constructor() {
this.rects = [];
this.gap = 12;
this.baseHeight = 100;
this.viewportWidth = 1024;
this.grabGap();
this.grabBaseHeight();
this.grabViewportWidth();
this.registerViewport();
this.draw();
}
// Register a new rectangle with an aspect ratio of ar at index
insert(ar=1, index=-1) {
const rect = {
ar: ar,
};
if (index == -1) {
this.rects.push(rect);
} else {
this.rects.splice(index, 0, rect);
// Extract gap values
grabGap() {
// Simple implementation to guess from padding values
const px = window.getComputedStyle(this.ele).getPropertyValue('padding-left').replace('px', '');
this.gap = parseInt(px) * 2 || 0;
}
// Extract baseHeight
grabBaseHeight() {
const px = window.getComputedStyle(this.ele).getPropertyValue('--gallery-base-height').replace('px', '');
this.baseHeight = parseInt(px) || 0;
}
// Extract viewport width
grabViewportWidth() {
this.viewportWidth = this.ele.clientWidth;
}
// Inform our engine of viewport width
registerViewport() {
window.addEventListener('resize', () => {
if (this.viewportWidth == window.innerWidth) {
return;
}
this.recompute();
});
window.addEventListener('load', () => {
this.recompute();
});
setInterval(this.recompute.bind(this), 500);
}
recompute() {
this.grabGap();
this.grabBaseHeight();
this.grabViewportWidth();
this.draw();
}
// Calculate dimensions and draw them
draw() {
const elements = this.ele.querySelectorAll('.gallery-item');
const rects = Array.from(elements).map(element => parseFloat(element.dataset.ar));
const dimensions = this.layout(rects);
const galleryItemEles = this.ele.querySelectorAll('.gallery-item');
for (const [index, ele] of galleryItemEles.entries()) {
const dimension = dimensions[index];
if (!dimension) {
console.error(`Missing dimensions for element at ${index}`);
return;
}
for (const imgEle of ele.querySelectorAll('img')) {
imgEle.style.height = `${Math.floor(dimension.h)}px`;
imgEle.style.width = `${dimension.w}px`;
}
}
}
// Unregister a new rectangle at index
pop(index=-1) {
// TODO
}
// Generate a list of dimensions for each rectangle
calculate() {
// Perform layout
layout(rects) {
let dimensions = [];
let currentWidth = this.gap;
let startIndex = 0;
// Behave like a browser: try to fit as many in a row with baseHeight
for (let index = 0; index < this.rects.length; index++) {
for (let index = 0; index < rects.length; index++) {
// Add this rectangle width
const currentRectWidth = (this.baseHeight * this.rects[index].ar);
const currentRectWidth = (this.baseHeight * rects[index]);
currentWidth += currentRectWidth + this.gap;
if (currentWidth > this.viewportWidth) {
@ -51,8 +93,8 @@ class LayoutEngine {
}
// Get the next width to add
const hasNextRect = index + 1 < this.rects.length;
const nextRectWidth = hasNextRect ? (this.baseHeight * this.rects[index+1].ar) : 0;
const hasNextRect = index + 1 < rects.length;
const nextRectWidth = hasNextRect ? (this.baseHeight * rects[index+1]) : 0;
const nextRectOverflow = currentWidth + nextRectWidth + this.gap > this.viewportWidth;
// If the next width is too wide, resolve the current rectangles
@ -68,7 +110,7 @@ class LayoutEngine {
for (; startIndex <= index; startIndex++) {
dimensions.push({
h: rectHeight,
w: rectHeight * this.rects[startIndex].ar,
w: rectHeight * rects[startIndex],
})
}
currentWidth = this.gap;
@ -78,104 +120,4 @@ class LayoutEngine {
return dimensions;
}
/*
* Code for a future implementation that emits events
update() {
console.debug('updating layout');
}
// Schedule an update at maximum 10Hz
scheduleUpdate() {
if (this.scheduledUpdate) {
return;
}
this.scheduledUpdate = setTimeout(() => {
this.scheduledUpdate = null;
this.update();
}, 100);
}
*/
}
export default class Gallery {
constructor(ele) {
this.ele = ele;
this.layoutEngine = new LayoutEngine();
this.grabGap();
this.grabBaseHeight();
this.grabViewportWidth();
this.registerViewport();
this.registerInitial();
this.draw();
}
// Extract gap values
grabGap() {
// Simple implementation to guess from padding values
const px = window.getComputedStyle(this.ele).getPropertyValue('padding-left').replace('px', '');
this.layoutEngine.gap = parseInt(px) * 2 || 0;
}
// Extract baseHeight
grabBaseHeight() {
const px = window.getComputedStyle(this.ele).getPropertyValue('--gallery-base-height').replace('px', '');
this.layoutEngine.baseHeight = parseInt(px) || 0;
}
// Extract viewport width
grabViewportWidth() {
this.layoutEngine.viewportWidth = this.ele.clientWidth;
}
// Inform our engine of viewport width
registerViewport() {
window.addEventListener('resize', () => {
if (this.layoutEngine.viewportWidth == window.innerWidth) {
return;
}
this.recompute();
});
window.addEventListener('load', () => {
this.recompute();
});
setInterval(this.recompute.bind(this), 500);
}
recompute() {
this.grabGap();
this.grabBaseHeight();
this.grabViewportWidth();
//this.layoutEngine.scheduleUpdate();
this.draw();
}
// Register initial elements
registerInitial() {
const galleryItemEles = this.ele.querySelectorAll('.gallery-item');
for (const ele of galleryItemEles) {
const ar = ele.dataset.ar;
this.layoutEngine.insert(ar);
}
}
// Calculate dimensions and draw them
draw() {
const dimensions = this.layoutEngine.calculate();
const galleryItemEles = this.ele.querySelectorAll('.gallery-item');
for (const [index, ele] of galleryItemEles.entries()) {
const dimension = dimensions[index];
if (!dimension) {
console.error(`Missing dimensions for element at ${index}`);
return;
}
for (const imgEle of ele.querySelectorAll('img')) {
imgEle.style.height = `${Math.floor(dimension.h)}px`;
imgEle.style.width = `${dimension.w}px`;
}
}
}
}

View File

@ -22,8 +22,8 @@
</header>
<main class="gallery">
{{ range .Photos }}
<div class="gallery-item preloaded-thumbnail" data-ar="{{ ar . }}">
<a href="{{ photo . }}" data-enlarge="{{ photo . }}" title="{{ photometadatatitle .Title }}" data-tags="{{ .Tags }}">
<div class="gallery-item preloaded-thumbnail" data-ar="{{ ar . }}" title="{{ photometadatatitle .Title }}" data-tags="{{ .Tags }}">
<a href="{{ photo . }}" data-enlarge="{{ photo . }}">
<picture style="display: none;">
<source srcset="{{ preview . 320 "image/webp" 70 }} 2x, {{ preview . 160 "image/webp" 70 }} 1x" type="image/webp" media="(max-width: 900px)">
<source srcset="{{ preview . 640 "image/webp" 70 }} 2x, {{ preview . 320 "image/webp" 70 }} 1x" type="image/webp">