Initial checkin

This commit is contained in:
2022-05-24 18:03:31 +09:30
commit 881c618041
31 changed files with 19870 additions and 0 deletions

0
web/static/css/app.css Normal file
View File

6456
web/static/css/foundation.css vendored Normal file

File diff suppressed because it is too large Load Diff

1
web/static/css/foundation.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

1
web/static/js/app.js Normal file
View File

@@ -0,0 +1 @@
$(document).foundation()

1
web/static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

531
web/static/js/vendor/foundation.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

10881
web/static/js/vendor/jquery.js vendored Normal file

File diff suppressed because it is too large Load Diff

517
web/static/js/vendor/what-input.js vendored Normal file
View File

@@ -0,0 +1,517 @@
/**
* what-input - A global utility for tracking the current input method (mouse, keyboard or touch).
* @version v5.2.10
* @link https://github.com/ten1seven/what-input
* @license MIT
*/
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory();
else if(typeof define === 'function' && define.amd)
define("whatInput", [], factory);
else if(typeof exports === 'object')
exports["whatInput"] = factory();
else
root["whatInput"] = factory();
})(this, function() {
return /******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId])
/******/ return installedModules[moduleId].exports;
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ exports: {},
/******/ id: moduleId,
/******/ loaded: false
/******/ };
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/ // Flag the module as loaded
/******/ module.loaded = true;
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/ // Load entry module and return exports
/******/ return __webpack_require__(0);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports) {
'use strict';
module.exports = function () {
/*
* bail out if there is no document or window
* (i.e. in a node/non-DOM environment)
*
* Return a stubbed API instead
*/
if (typeof document === 'undefined' || typeof window === 'undefined') {
return {
// always return "initial" because no interaction will ever be detected
ask: function ask() {
return 'initial';
},
// always return null
element: function element() {
return null;
},
// no-op
ignoreKeys: function ignoreKeys() {},
// no-op
specificKeys: function specificKeys() {},
// no-op
registerOnChange: function registerOnChange() {},
// no-op
unRegisterOnChange: function unRegisterOnChange() {}
};
}
/*
* variables
*/
// cache document.documentElement
var docElem = document.documentElement;
// currently focused dom element
var currentElement = null;
// last used input type
var currentInput = 'initial';
// last used input intent
var currentIntent = currentInput;
// UNIX timestamp of current event
var currentTimestamp = Date.now();
// check for a `data-whatpersist` attribute on either the `html` or `body` elements, defaults to `true`
var shouldPersist = 'false';
// form input types
var formInputs = ['button', 'input', 'select', 'textarea'];
// empty array for holding callback functions
var functionList = [];
// list of modifier keys commonly used with the mouse and
// can be safely ignored to prevent false keyboard detection
var ignoreMap = [16, // shift
17, // control
18, // alt
91, // Windows key / left Apple cmd
93 // Windows menu / right Apple cmd
];
var specificMap = [];
// mapping of events to input types
var inputMap = {
keydown: 'keyboard',
keyup: 'keyboard',
mousedown: 'mouse',
mousemove: 'mouse',
MSPointerDown: 'pointer',
MSPointerMove: 'pointer',
pointerdown: 'pointer',
pointermove: 'pointer',
touchstart: 'touch',
touchend: 'touch'
// boolean: true if the page is being scrolled
};var isScrolling = false;
// store current mouse position
var mousePos = {
x: null,
y: null
// map of IE 10 pointer events
};var pointerMap = {
2: 'touch',
3: 'touch', // treat pen like touch
4: 'mouse'
// check support for passive event listeners
};var supportsPassive = false;
try {
var opts = Object.defineProperty({}, 'passive', {
get: function get() {
supportsPassive = true;
}
});
window.addEventListener('test', null, opts);
} catch (e) {}
// fail silently
/*
* set up
*/
var setUp = function setUp() {
// add correct mouse wheel event mapping to `inputMap`
inputMap[detectWheel()] = 'mouse';
addListeners();
};
/*
* events
*/
var addListeners = function addListeners() {
// `pointermove`, `MSPointerMove`, `mousemove` and mouse wheel event binding
// can only demonstrate potential, but not actual, interaction
// and are treated separately
var options = supportsPassive ? { passive: true } : false;
document.addEventListener('DOMContentLoaded', setPersist);
// pointer events (mouse, pen, touch)
if (window.PointerEvent) {
window.addEventListener('pointerdown', setInput);
window.addEventListener('pointermove', setIntent);
} else if (window.MSPointerEvent) {
window.addEventListener('MSPointerDown', setInput);
window.addEventListener('MSPointerMove', setIntent);
} else {
// mouse events
window.addEventListener('mousedown', setInput);
window.addEventListener('mousemove', setIntent);
// touch events
if ('ontouchstart' in window) {
window.addEventListener('touchstart', setInput, options);
window.addEventListener('touchend', setInput);
}
}
// mouse wheel
window.addEventListener(detectWheel(), setIntent, options);
// keyboard events
window.addEventListener('keydown', setInput);
window.addEventListener('keyup', setInput);
// focus events
window.addEventListener('focusin', setElement);
window.addEventListener('focusout', clearElement);
};
// checks if input persistence should happen and
// get saved state from session storage if true (defaults to `false`)
var setPersist = function setPersist() {
shouldPersist = !(docElem.getAttribute('data-whatpersist') || document.body.getAttribute('data-whatpersist') === 'false');
if (shouldPersist) {
// check for session variables and use if available
try {
if (window.sessionStorage.getItem('what-input')) {
currentInput = window.sessionStorage.getItem('what-input');
}
if (window.sessionStorage.getItem('what-intent')) {
currentIntent = window.sessionStorage.getItem('what-intent');
}
} catch (e) {
// fail silently
}
}
// always run these so at least `initial` state is set
doUpdate('input');
doUpdate('intent');
};
// checks conditions before updating new input
var setInput = function setInput(event) {
var eventKey = event.which;
var value = inputMap[event.type];
if (value === 'pointer') {
value = pointerType(event);
}
var ignoreMatch = !specificMap.length && ignoreMap.indexOf(eventKey) === -1;
var specificMatch = specificMap.length && specificMap.indexOf(eventKey) !== -1;
var shouldUpdate = value === 'keyboard' && eventKey && (ignoreMatch || specificMatch) || value === 'mouse' || value === 'touch';
// prevent touch detection from being overridden by event execution order
if (validateTouch(value)) {
shouldUpdate = false;
}
if (shouldUpdate && currentInput !== value) {
currentInput = value;
persistInput('input', currentInput);
doUpdate('input');
}
if (shouldUpdate && currentIntent !== value) {
// preserve intent for keyboard interaction with form fields
var activeElem = document.activeElement;
var notFormInput = activeElem && activeElem.nodeName && (formInputs.indexOf(activeElem.nodeName.toLowerCase()) === -1 || activeElem.nodeName.toLowerCase() === 'button' && !checkClosest(activeElem, 'form'));
if (notFormInput) {
currentIntent = value;
persistInput('intent', currentIntent);
doUpdate('intent');
}
}
};
// updates the doc and `inputTypes` array with new input
var doUpdate = function doUpdate(which) {
docElem.setAttribute('data-what' + which, which === 'input' ? currentInput : currentIntent);
fireFunctions(which);
};
// updates input intent for `mousemove` and `pointermove`
var setIntent = function setIntent(event) {
var value = inputMap[event.type];
if (value === 'pointer') {
value = pointerType(event);
}
// test to see if `mousemove` happened relative to the screen to detect scrolling versus mousemove
detectScrolling(event);
// only execute if scrolling isn't happening
if ((!isScrolling && !validateTouch(value) || isScrolling && event.type === 'wheel' || event.type === 'mousewheel' || event.type === 'DOMMouseScroll') && currentIntent !== value) {
currentIntent = value;
persistInput('intent', currentIntent);
doUpdate('intent');
}
};
var setElement = function setElement(event) {
if (!event.target.nodeName) {
// If nodeName is undefined, clear the element
// This can happen if click inside an <svg> element.
clearElement();
return;
}
currentElement = event.target.nodeName.toLowerCase();
docElem.setAttribute('data-whatelement', currentElement);
if (event.target.classList && event.target.classList.length) {
docElem.setAttribute('data-whatclasses', event.target.classList.toString().replace(' ', ','));
}
};
var clearElement = function clearElement() {
currentElement = null;
docElem.removeAttribute('data-whatelement');
docElem.removeAttribute('data-whatclasses');
};
var persistInput = function persistInput(which, value) {
if (shouldPersist) {
try {
window.sessionStorage.setItem('what-' + which, value);
} catch (e) {
// fail silently
}
}
};
/*
* utilities
*/
var pointerType = function pointerType(event) {
if (typeof event.pointerType === 'number') {
return pointerMap[event.pointerType];
} else {
// treat pen like touch
return event.pointerType === 'pen' ? 'touch' : event.pointerType;
}
};
// prevent touch detection from being overridden by event execution order
var validateTouch = function validateTouch(value) {
var timestamp = Date.now();
var touchIsValid = value === 'mouse' && currentInput === 'touch' && timestamp - currentTimestamp < 200;
currentTimestamp = timestamp;
return touchIsValid;
};
// detect version of mouse wheel event to use
// via https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event
var detectWheel = function detectWheel() {
var wheelType = null;
// Modern browsers support "wheel"
if ('onwheel' in document.createElement('div')) {
wheelType = 'wheel';
} else {
// Webkit and IE support at least "mousewheel"
// or assume that remaining browsers are older Firefox
wheelType = document.onmousewheel !== undefined ? 'mousewheel' : 'DOMMouseScroll';
}
return wheelType;
};
// runs callback functions
var fireFunctions = function fireFunctions(type) {
for (var i = 0, len = functionList.length; i < len; i++) {
if (functionList[i].type === type) {
functionList[i].fn.call(undefined, type === 'input' ? currentInput : currentIntent);
}
}
};
// finds matching element in an object
var objPos = function objPos(match) {
for (var i = 0, len = functionList.length; i < len; i++) {
if (functionList[i].fn === match) {
return i;
}
}
};
var detectScrolling = function detectScrolling(event) {
if (mousePos.x !== event.screenX || mousePos.y !== event.screenY) {
isScrolling = false;
mousePos.x = event.screenX;
mousePos.y = event.screenY;
} else {
isScrolling = true;
}
};
// manual version of `closest()`
var checkClosest = function checkClosest(elem, tag) {
var ElementPrototype = window.Element.prototype;
if (!ElementPrototype.matches) {
ElementPrototype.matches = ElementPrototype.msMatchesSelector || ElementPrototype.webkitMatchesSelector;
}
if (!ElementPrototype.closest) {
do {
if (elem.matches(tag)) {
return elem;
}
elem = elem.parentElement || elem.parentNode;
} while (elem !== null && elem.nodeType === 1);
return null;
} else {
return elem.closest(tag);
}
};
/*
* init
*/
// don't start script unless browser cuts the mustard
// (also passes if polyfills are used)
if ('addEventListener' in window && Array.prototype.indexOf) {
setUp();
}
/*
* api
*/
return {
// returns string: the current input type
// opt: 'intent'|'input'
// 'input' (default): returns the same value as the `data-whatinput` attribute
// 'intent': includes `data-whatintent` value if it's different than `data-whatinput`
ask: function ask(opt) {
return opt === 'intent' ? currentIntent : currentInput;
},
// returns string: the currently focused element or null
element: function element() {
return currentElement;
},
// overwrites ignored keys with provided array
ignoreKeys: function ignoreKeys(arr) {
ignoreMap = arr;
},
// overwrites specific char keys to update on
specificKeys: function specificKeys(arr) {
specificMap = arr;
},
// attach functions to input and intent "events"
// funct: function to fire on change
// eventType: 'input'|'intent'
registerOnChange: function registerOnChange(fn, eventType) {
functionList.push({
fn: fn,
type: eventType || 'input'
});
},
unRegisterOnChange: function unRegisterOnChange(fn) {
var position = objPos(fn);
if (position || position === 0) {
functionList.splice(position, 1);
}
},
clearStorage: function clearStorage() {
window.sessionStorage.clear();
}
};
}();
/***/ })
/******/ ])
});
;

View File

@@ -0,0 +1,53 @@
<!doctype html>
<html class="no-js" lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>linkwallet</title>
<link rel="stylesheet" href="/assets/css/foundation.css">
<link rel="stylesheet" href="/assets/css/app.css">
<script src="/assets/js/htmx.min.js" defer></script>
</head>
<body>
<div class="top-bar">
<div class="top-bar-left">
<ul class="dropdown menu" data-dropdown-menu>
<li class="menu-text">linkwallet</li>
<li><a href="/">Home</a></li>
<li>
<a href="#">Admin</a>
<ul class="menu vertical">
<li><a href="/manage">Manage links</a></li>
</ul>
</li>
</ul>
</div>
<div class="top-bar-right">
<ul class="menu">
<li><a href="https://github.com">gh</a></li>
<!-- <li><input type="search" placeholder="Search"></li>
<li><button type="button" class="button">Search</button></li> -->
</ul>
</div>
</div>
<div class="grid-container">
{{ if eq .page "root" }}
{{ template "search.html" . }}
{{ else if eq .page "manage" }}
{{ template "manage.html" . }}
{{ end }}
{{/* template "foundation_sample.html" . */}}
</div>
<script src="/assets/js/vendor/jquery.js"></script>
<script src="/assets/js/vendor/what-input.js"></script>
<script src="/assets/js/vendor/foundation.js"></script>
<script src="/assets/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<div class="large-8 medium-8 cell" id="add-url-form" >
<h5>Add a new URL</h5>
<span>[<a hx-get="/bulk_add" hx-target="#add-url-form" href="#">bulk</a>]</span>
<form onsubmit="return false">
<div class="grid-x grid-padding-x">
<div class="large-6 cell">
<label>Paste a URL</label>
<input type="text" name="url"
hx-post="/add"
hx-target="#add-url-form" hx-trigger=""
/>
</div>
<div class="large-6 cell">
{{ template "tags_widget.html" . }}
</div>
</div>
</form>
{{ if .error }}
<p class="error">{{ .error }}</p>
{{ else }}
{{ if .bm }}
Bookmark added - ID: {{ .bm.ID }} URL: {{ .bm.URL }}
{{ end }}
{{ end }}
</div>

View File

@@ -0,0 +1,36 @@
<div class="large-8 medium-8 cell" id="add-url-form" >
<h5>Add URLs in bulk</h5>
<span>[<a hx-get="/single_add" hx-target="#add-url-form" href="#">single</a>]</span>
<form onsubmit="return false">
<div class="grid-x grid-padding-x">
<div class="large-12 cell">
<label>Paste URL's, one per line</label>
<textarea type="text" name="urls" rows="10"
></textarea>
</div>
</div>
<button
class="button"
hx-post="/add_bulk"
hx-indicator="#htmx-indicator-bulk"
hx-target="#add-url-form">
add
</button>
<span id="htmx-indicator-bulk" class="htmx-indicator">
<img src="/assets/image/beating.gif" /> adding...
</span>
</form>
{{ if .errors }}
<ul>
{{ range .errors }}
<li class="error">{{ . }}</li>
{{ end }}
</ul>
{{ else }}
{{ if .added }}
Added {{ .added }} urls
{{ end }}
{{ end }}
</div>

View File

@@ -0,0 +1,148 @@
<div class="grid-x grid-padding-x">
<div class="large-12 cell">
<h1>Welcome to Foundation</h1>
</div>
</div>
<div class="grid-x grid-padding-x">
<div class="large-12 cell">
<div class="callout">
<h3>We&rsquo;re stoked you want to try Foundation! </h3>
<p>To get going, this file (index.html) includes some basic styles you can modify, play around with, or totally destroy to get going.</p>
<p>Once you've exhausted the fun in this document, you should check out:</p>
<div class="grid-x grid-padding-x">
<div class="large-4 medium-4 cell">
<p><a href="https://get.foundation/sites/docs/">Foundation Documentation</a><br />Everything you need to know about using the framework.</p>
</div>
<div class="large-4 medium-4 cell">
<p><a href="https://github.com/foundation/foundation-sites/discussions">Foundation Forum</a><br />Join the Foundation community to ask a question or show off your knowlege.</p>
</div>
</div>
<div class="grid-x grid-padding-x">
<div class="large-4 medium-4 medium-push-2 cell">
<p><a href="https://github.com/foundation/foundation-sites">Foundation on Github</a><br />Latest code, issue reports, feature requests and more.</p>
</div>
<div class="large-4 medium-4 medium-pull-2 cell">
<p><a href="https://twitter.com/FoundationCSS">@FoundationCSS</a><br />Ping us on Twitter if you have questions. When you build something with this we'd love to see it.</p>
</div>
</div>
</div>
</div>
</div>
<div class="grid-x grid-padding-x">
<div class="large-8 medium-8 cell">
<h5>Here&rsquo;s your basic grid:</h5>
<!-- Grid Example -->
<div class="grid-x grid-padding-x">
<div class="large-12 cell">
<div class="primary callout">
<p><strong>This is a twelve cell section in a grid-x.</strong> Each of these includes a div.callout element so you can see where the cell are - it's not required at all for the grid.</p>
</div>
</div>
</div>
<div class="grid-x grid-padding-x">
<div class="large-6 medium-6 cell">
<div class="primary callout">
<p>Six cell</p>
</div>
</div>
<div class="large-6 medium-6 cell">
<div class="primary callout">
<p>Six cell</p>
</div>
</div>
</div>
<div class="grid-x grid-padding-x">
<div class="large-4 medium-4 small-4 cell">
<div class="primary callout">
<p>Four cell</p>
</div>
</div>
<div class="large-4 medium-4 small-4 cell">
<div class="primary callout">
<p>Four cell</p>
</div>
</div>
<div class="large-4 medium-4 small-4 cell">
<div class="primary callout">
<p>Four cell</p>
</div>
</div>
</div>
<hr />
<h5>We bet you&rsquo;ll need a form somewhere:</h5>
<form>
<div class="grid-x grid-padding-x">
<div class="large-12 cell">
<label>Input Label</label>
<input type="text" placeholder="large-12.cell" />
</div>
</div>
<div class="grid-x grid-padding-x">
<div class="large-4 medium-4 cell">
<label>Input Label</label>
<input type="text" placeholder="large-4.cell" />
</div>
<div class="large-4 medium-4 cell">
<label>Input Label</label>
<input type="text" placeholder="large-4.cell" />
</div>
<div class="large-4 medium-4 cell">
<div class="grid-x">
<label>Input Label</label>
<div class="input-group">
<input type="text" placeholder="small-9.cell" class="input-group-field" />
<span class="input-group-label">.com</span>
</div>
</div>
</div>
</div>
<div class="grid-x grid-padding-x">
<div class="large-12 cell">
<label>Select Box</label>
<select>
<option value="husker">Husker</option>
<option value="starbuck">Starbuck</option>
<option value="hotdog">Hot Dog</option>
<option value="apollo">Apollo</option>
</select>
</div>
</div>
<div class="grid-x grid-padding-x">
<div class="large-6 medium-6 cell">
<label>Choose Your Favorite</label>
<input type="radio" name="pokemon" value="Red" id="pokemonRed"><label for="pokemonRed">Radio 1</label>
<input type="radio" name="pokemon" value="Blue" id="pokemonBlue"><label for="pokemonBlue">Radio 2</label>
</div>
<div class="large-6 medium-6 cell">
<label>Check these out</label>
<input id="checkbox1" type="checkbox"><label for="checkbox1">Checkbox 1</label>
<input id="checkbox2" type="checkbox"><label for="checkbox2">Checkbox 2</label>
</div>
</div>
<div class="grid-x grid-padding-x">
<div class="large-12 cell">
<label>Textarea Label</label>
<textarea placeholder="small-12.cell"></textarea>
</div>
</div>
</form>
</div>
<div class="large-4 medium-4 cell">
<h5>Try one of these buttons:</h5>
<p><a href="#" class="button">Simple Button</a><br/>
<a href="#" class="success button">Success Btn</a><br/>
<a href="#" class="alert button">Alert Btn</a><br/>
<a href="#" class="secondary button">Secondary Btn</a></p>
<div class="callout">
<h5>So many components, girl!</h5>
<p>A whole kitchen sink of goodies comes with Foundation. Check out the docs to see them all, along with details on making them your own.</p>
<a href="https://get.foundation/sites/docs/" class="small button">Go to Foundation Docs</a>
</div>
</div>
</div>

29
web/templates/manage.html Normal file
View File

@@ -0,0 +1,29 @@
<div class="grid-x grid-padding-x">
<div class="large-12 cell">
<h5>Manage:
</h5>
<table>
<tr><th>id</th><th>url</th><th>created</th><th>scraped</th></tr>
{{ range .bookmarks }}
<tr>
<th>{{ .ID }}</th>
<td>
<a href="{{ .URL }}">{{ .Info.Title }}</a>
<br>
<a href="{{ .URL }}">{{ niceURL .URL }}</a>
</td>
<td>{{ (nicetime .TimestampCreated).HumanDuration }} ago</td>
<td>{{ (nicetime .TimestampLastScraped).HumanDuration }} ago</td>
<td>
<button class="button" hx-post="/scrape/{{ .ID }}">scrape</button>
</td>
</tr>
{{ end }}
</table>
</div>
</div>

41
web/templates/search.html Normal file
View File

@@ -0,0 +1,41 @@
<div class="grid-x grid-padding-x">
<div class="large-8 medium-8 cell">
<h5>Search:
<span id="htmx-indicator-search" class="htmx-indicator">
<img src="/assets/image/beating.gif" /> Searching...
</span>
</h5>
<form onsubmit="return false">
<div class="grid-x grid-padding-x">
<div class="large-12 cell">
<label>Free text</label>
<input type="text" name="query" placeholder="" hx-post="/search"
hx-trigger="keyup changed delay:50ms, search" hx-target="#search-results"
hx-indicator="#htmx-indicator-search" />
</div>
</div>
</form>
<div id="search-results">
</div>
</div>
{{ template "add_url_form.html" . }}
<!-- <div class="large-4 medium-4 cell">
<h5>Try one of these buttons:</h5>
<p><a href="#" class="button">Simple Button</a><br />
<a href="#" class="success button">Success Btn</a><br />
<a href="#" class="alert button">Alert Btn</a><br />
<a href="#" class="secondary button">Secondary Btn</a>
</p>
<div class="callout">
<h5>So many components, girl!</h5>
<p>A whole kitchen sink of goodies comes with Foundation. Check out the docs to see them all, along with
details on making them your own.</p>
<a href="https://get.foundation/sites/docs/" class="small button">Go to Foundation Docs</a>
</div>
</div> -->
</div>

View File

@@ -0,0 +1,5 @@
<ul>
{{ range .results }}
<li><a href="{{ .URL }}">{{ .Info.Title }}</a> - {{ .URL }}</li>
{{ end }}
</ul>

View File

@@ -0,0 +1,28 @@
<div id="label-widget">
<div class="grid-x grid-padding-x">
<div class="small-3 medium-2 large-1 cell">
</div>
<div class="small-9 medium-10 large-5 cell"
hx-post="/tags"
hx-target="#label-widget"
hx-trigger="change">
<label for="tag-entry"
class="Xtext-right Xmiddle">Tags</label>
<input id="tag-entry" type="text" name="tag" placeholder="enter tags" />
</div>
<div class="small-12 large-6 cell">
{{ range .tags }}
<a href="#"
title="remove {{ . }}"
hx-trigger="click"
hx-target="#label-widget"
hx-post="/tags?remove={{ . }}">
{{ . }}
</a>
{{ end }}
<input type="hidden" name="tags_hidden" value="{{ .tags_hidden }}">
</div>
</div>
</div>

248
web/web.go Normal file
View File

@@ -0,0 +1,248 @@
package web
import (
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/tardisx/linkwallet/db"
"github.com/tardisx/linkwallet/entity"
"github.com/hako/durafmt"
"github.com/gin-gonic/gin"
)
//go:embed static/*
var staticFiles embed.FS
//go:embed templates/*
var templateFiles embed.FS
// Server represents a SCUD web server.
// The SCUD web service can serve 2 different kinda of responses. The first is basic static
// vendor-provided files (called "assetFiles" here). An arbitrary number of them can be placed
// in assets/ and served up via a path prefix of /assets. They do not need individual routes
// to be specified.
// The second is htmx responses fragments. We never automatically serve templates (ie no mapping
// from template name to a URL route), there will always be a specific route or routes which
// use one or more templates to return a response.
type Server struct {
engine *gin.Engine
templ *template.Template
bmm *db.BookmarkManager
}
// Create creates a new web server instance and sets up routing.
func Create(bmm *db.BookmarkManager) *Server {
// setup routes for the static assets (vendor includes)
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
log.Fatalf("problem with assetFS: %s", err)
}
// templ := template.Must(template.New("").Funcs(template.FuncMap{"dict": dictHelper}).ParseFS(templateFiles, "templates/*.html"))
templ := template.Must(template.New("").Funcs(template.FuncMap{"nicetime": niceTime, "niceURL": niceURL}).ParseFS(templateFiles, "templates/*.html"))
r := gin.Default()
server := &Server{
engine: r,
templ: templ,
bmm: bmm,
}
r.Use(headersByURI())
r.SetHTMLTemplate(templ)
r.StaticFS("/assets", http.FS(staticFS))
r.GET("/", func(c *gin.Context) {
meta := gin.H{"page": "root"}
c.HTML(http.StatusOK,
"_layout.html", meta,
)
})
r.GET("/manage", func(c *gin.Context) {
allBookmarks, _ := bmm.ListBookmarks()
meta := gin.H{"page": "manage", "bookmarks": allBookmarks}
c.HTML(http.StatusOK,
"_layout.html", meta,
)
})
r.POST("/search", func(c *gin.Context) {
query := c.PostForm("query")
sr, err := bmm.Search(query)
data := gin.H{
"results": sr,
"error": err,
}
c.HTML(http.StatusOK,
"search_results.html", data,
)
})
r.POST("/add", func(c *gin.Context) {
url := c.PostForm("url")
tags := strings.Split(c.PostForm("tags_hidden"), "|")
bm := entity.Bookmark{
ID: 0,
URL: url,
Tags: tags,
}
err := bmm.AddBookmark(&bm)
log.Printf("well done %d", bm.ID)
data := gin.H{
"bm": bm,
"error": err,
}
c.HTML(http.StatusOK, "add_url_form.html", data)
})
r.POST("/add_bulk", func(c *gin.Context) {
urls := c.PostForm("urls")
urlsSplit := strings.Split(urls, "\n")
urlsTrimmed := make([]string, 0, 0)
for _, url := range urlsSplit {
urlsTrimmed = append(urlsTrimmed, strings.TrimSpace(url))
}
totalErrors := make([]string, 0, 0)
added := 0
for _, url := range urlsTrimmed {
if url != "" {
bm := entity.Bookmark{
ID: 0,
URL: url,
}
err := bmm.AddBookmark(&bm)
if err != nil {
totalErrors = append(totalErrors, fmt.Sprintf("url: %s (%s)", url, err.Error()))
} else {
added++
}
}
}
log.Printf("well done %v, %d", totalErrors, added)
data := gin.H{
"added": added,
"errors": totalErrors,
}
c.HTML(http.StatusOK, "add_url_form_bulk.html", data)
})
r.GET("/bulk_add", func(c *gin.Context) {
c.HTML(http.StatusOK, "add_url_form_bulk.html", nil)
})
r.POST("/tags", func(c *gin.Context) {
log.Printf("POST: tag '%s' tags_hidden '%s'", c.PostForm("tag"), c.PostForm("tags_hidden"))
newTag := c.PostForm("tag")
oldTags := strings.Split(c.PostForm("tags_hidden"), "|")
remove := c.Query("remove")
if remove != "" {
log.Printf("removing %s", remove)
trimmedTags := []string{}
for _, k := range oldTags {
if k != remove {
trimmedTags = append(trimmedTags, k)
}
}
oldTags = trimmedTags
}
tags := append(oldTags, newTag)
tags = cleanupTags(tags)
tagsHidden := strings.Join(tags, "|")
data := gin.H{"tags": tags, "tags_hidden": tagsHidden}
c.HTML(http.StatusOK, "tags_widget.html", data)
})
r.GET("/single_add", func(c *gin.Context) {
c.HTML(http.StatusOK, "add_url_form.html", nil)
})
// XXX this should properly replace the button
r.POST("/scrape/:id", func(c *gin.Context) {
id := c.Params.ByName("id")
idNum, _ := strconv.ParseInt(id, 10, 32)
bm := bmm.LoadBookmarkByID(uint64(idNum))
bmm.QueueScrape(&bm)
c.String(http.StatusOK, "queued")
})
return server
}
// headersByURI sets the headers for some special cases, set a custom long cache time for
// static resources.
func headersByURI() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.String(), "/assets/") {
c.Header("Cache-Control", "max-age=86400")
c.Header("Expires", time.Now().Add(time.Hour*24*1).Format("Mon 2 Jan 2006 15:04:05 MST"))
}
}
}
// Start starts the web server, blocking forever.
func (s *Server) Start() {
s.engine.Run()
}
func cleanupTags(tags []string) []string {
keys := make(map[string]struct{})
for _, k := range tags {
if k != "" && k != "|" {
keys[k] = struct{}{}
}
}
out := []string{}
for k := range keys {
out = append(out, k)
}
sort.Strings(out)
return out
}
type timeVariations struct {
HumanDuration string
}
func niceTime(t time.Time) timeVariations {
u := "y:y,w:w,d:d,h:h,m:m,s:s,ms:ms,us:us"
units, err := durafmt.DefaultUnitsCoder.Decode(u)
if err != nil {
panic(err)
}
ago := durafmt.Parse(time.Since(t)).LimitFirstN(1).Format(units)
return timeVariations{HumanDuration: ago}
}
func niceURL(url string) string {
if len(url) > 50 {
return url[0:50] + " ..."
}
return url
}