This commit is contained in:
max_richter 2021-03-09 01:13:42 +01:00
commit 7fc8feb0cc
32 changed files with 3700 additions and 0 deletions

11
Makefile Executable file
View File

@ -0,0 +1,11 @@
MAKEFLAGS += -j2
export PORT = 8080
dev: dev-server dev-view
dev-server:
cd server && gin run main.go
dev-view:
cd view && npm run dev

1
server/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
gin-bin

5
server/go.mod Executable file
View File

@ -0,0 +1,5 @@
module git.jim-fx.com/max/karl-server
go 1.16
require github.com/gin-gonic/gin v1.6.3

42
server/go.sum Executable file
View File

@ -0,0 +1,42 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

33
server/main.go Executable file
View File

@ -0,0 +1,33 @@
package main
import (
"fmt"
"os"
"github.com/gin-gonic/gin"
)
var db = make(map[string]string)
func setupRouter() *gin.Engine {
// Disable Console Color
// gin.DisableConsoleColor()
r := gin.Default()
r.Static("/", "../view/public")
return r
}
func main() {
r := setupRouter()
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
fmt.Print("PORT: " + port)
r.Run(":" + port)
}

4
view/.gitignore vendored Executable file
View File

@ -0,0 +1,4 @@
/node_modules/
/public/build/
.DS_Store

2342
view/package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

32
view/package.json Executable file
View File

@ -0,0 +1,32 @@
{
"name": "svelte-app",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"start": "sirv public",
"validate": "svelte-check"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-node-resolve": "^11.0.0",
"@rollup/plugin-typescript": "^8.0.0",
"@tsconfig/svelte": "^1.0.10",
"idb": "^6.0.0",
"rollup": "^2.3.4",
"rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.0.0",
"rollup-plugin-terser": "^7.0.0",
"svelte": "^3.0.0",
"svelte-check": "^1.0.0",
"svelte-preprocess": "^4.0.0",
"tslib": "^2.0.0",
"typescript": "^4.0.0"
},
"dependencies": {
"sirv-cli": "^1.0.0",
"svelte-file-dropzone": "^0.0.15"
}
}

BIN
view/public/favicon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

62
view/public/global.css Executable file
View File

@ -0,0 +1,62 @@
html, body {
position: relative;
width: 100%;
height: 100%;
}
body {
margin: 0;
height: 100%;
box-sizing: border-box;
font-family: Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}
a {
color: rgb(0,100,200);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:visited {
color: rgb(0,80,160);
}
label {
display: block;
}
input, button, select, textarea {
font-family: inherit;
font-size: inherit;
-webkit-padding: 0.4em 0;
padding: 0.4em;
margin: 0 0 0.5em 0;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 2px;
}
input:disabled {
color: #ccc;
}
button {
background-color: #f4f4f4;
outline: none;
cursor: pointer;
}
button:disabled {
color: #999;
}
button:not(:disabled):active {
background-color: #ddd;
}
button:focus {
border-color: #666;
}

23
view/public/index.html Executable file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>Karls Analyzer</title>
<link rel='icon' type='image/png' href='/favicon.png'>
<link rel='stylesheet' href='/global.css'>
<link rel='stylesheet' href='/build/bundle.css'>
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;900&display=swap" rel="stylesheet">
<script defer src='/build/bundle.js'></script>
</head>
<body>
</body>
</html>

56
view/rollup.config.js Executable file
View File

@ -0,0 +1,56 @@
import svelte from 'rollup-plugin-svelte';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import { terser } from 'rollup-plugin-terser';
import sveltePreprocess from 'svelte-preprocess';
import typescript from '@rollup/plugin-typescript';
import css from 'rollup-plugin-css-only';
const production = !process.env.ROLLUP_WATCH;
export default {
input: 'src/main.ts',
output: {
sourcemap: true,
format: 'iife',
name: 'app',
file: 'public/build/bundle.js'
},
plugins: [
svelte({
preprocess: sveltePreprocess({ sourceMap: !production }),
compilerOptions: {
// enable run-time checks when not in production
dev: !production
}
}),
// we'll extract any component CSS out into
// a separate file - better for performance
css({ output: 'bundle.css' }),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ['svelte']
}),
commonjs(),
typescript({
sourceMap: !production,
inlineSources: !production
}),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser()
],
watch: {
clearScreen: false,
chokidar: {
followSymlinks: true
}
}
};

25
view/src/App.svelte Executable file
View File

@ -0,0 +1,25 @@
<script lang="ts">
import * as routes from "./routes";
import ToastWrapper from "./components/Toast/ToastWrapper.svelte";
import { route as currentRoute } from "./stores";
</script>
<main>
{#if $currentRoute in routes}
<svelte:component this={routes[$currentRoute]} />
{/if}
<ToastWrapper />
</main>
<style>
main {
height: 100%;
}
@media (min-width: 640px) {
main {
max-width: none;
}
}
</style>

View File

@ -0,0 +1,80 @@
<script>
export let num;
let prevNumber = num;
let dirUp;
let isAnimating = false;
let startAnimate = false;
const duration = 500;
$: if (num !== undefined) {
if (num !== prevNumber) {
isAnimating = true;
dirUp = prevNumber < num;
setTimeout(() => {
startAnimate = true;
}, 100);
setTimeout(() => {
isAnimating = false;
startAnimate = false;
prevNumber = num;
}, duration);
}
}
</script>
<div class="wrapper" class:isAnimating>
<span>
{num}
</span>
{#if isAnimating}
<span
style={`transition: transform ${duration}ms ease, opacity ${duration}ms ease;`}
class:down={startAnimate && dirUp}
class:up={startAnimate && !dirUp}
>
{prevNumber}
</span>
<span
style={`transition: transform ${duration}ms ease, opacity ${duration}ms ease;`}
class:down={!startAnimate && !dirUp}
class:up={!startAnimate && dirUp}
>
{num}
</span>
{/if}
</div>
<style>
.wrapper {
position: relative;
display: inline-block;
}
.wrapper.isAnimating > span:nth-child(1) {
visibility: hidden;
}
.up {
transform: translateY(-10px) !important;
opacity: 0 !important;
}
.down {
transform: translateY(10px) !important;
opacity: 0 !important;
}
.wrapper > span:nth-child(2),
.wrapper > span:nth-child(3) {
position: absolute;
opacity: 1;
left: 0px;
top: 0px;
}
</style>

View File

@ -0,0 +1,350 @@
<script>
import { fromEvent } from "file-selector";
import {
allFilesAccepted,
composeEventHandlers,
fileAccepted,
fileMatchSize,
isEvtWithFiles,
isIeOrEdge,
isPropagationStopped,
onDocumentDragOver,
TOO_MANY_FILES_REJECTION,
} from "./utils";
import { onMount, onDestroy, createEventDispatcher } from "svelte";
//props
/**
* Set accepted file types.
* See https://github.com/okonet/attr-accept for more information.
*/
export let accept; // string or string[]
export let disabled = false;
export let getFilesFromEvent = fromEvent;
export let maxSize = Infinity;
export let minSize = 0;
export let multiple = true;
export let preventDropOnDocument = true;
export let noClick = false;
export let noKeyboard = false;
export let noDrag = false;
export let noDragEventsBubbling = false;
export let containerClasses = "";
export let containerStyles = "";
export let disableDefaultStyles = false;
const dispatch = createEventDispatcher();
//state
let state = {
isFocused: false,
isFileDialogActive: false,
isDragActive: false,
isDragAccept: false,
isDragReject: false,
draggedFiles: [],
acceptedFiles: [],
fileRejections: [],
};
let rootRef;
let inputRef;
function resetState() {
state.isFileDialogActive = false;
state.isDragActive = false;
state.draggedFiles = [];
state.acceptedFiles = [];
state.fileRejections = [];
}
// Fn for opening the file dialog programmatically
function openFileDialog() {
if (inputRef) {
inputRef.value = null; // TODO check if null needs to be set
state.isFileDialogActive = true;
inputRef.click();
}
}
// Cb to open the file dialog when SPACE/ENTER occurs on the dropzone
function onKeyDownCb(event) {
// Ignore keyboard events bubbling up the DOM tree
if (!rootRef || !rootRef.isEqualNode(event.target)) {
return;
}
if (event.keyCode === 32 || event.keyCode === 13) {
event.preventDefault();
openFileDialog();
}
}
// Update focus state for the dropzone
function onFocusCb() {
state.isFocused = true;
}
function onBlurCb() {
state.isFocused = false;
}
// Cb to open the file dialog when click occurs on the dropzone
function onClickCb() {
if (noClick) {
return;
}
// In IE11/Edge the file-browser dialog is blocking, therefore, use setTimeout()
// to ensure React can handle state changes
// See: https://github.com/react-dropzone/react-dropzone/issues/450
if (isIeOrEdge()) {
setTimeout(openFileDialog, 0);
} else {
openFileDialog();
}
}
function onDragEnterCb(event) {
event.preventDefault();
stopPropagation(event);
dragTargetsRef = [...dragTargetsRef, event.target];
if (isEvtWithFiles(event)) {
Promise.resolve(getFilesFromEvent(event)).then((draggedFiles) => {
if (isPropagationStopped(event) && !noDragEventsBubbling) {
return;
}
state.draggedFiles = draggedFiles;
state.isDragActive = true;
dispatch("dragenter", {
dragEvent: event,
});
});
}
}
function onDragOverCb(event) {
event.preventDefault();
stopPropagation(event);
if (event.dataTransfer) {
try {
event.dataTransfer.dropEffect = "copy";
} catch {} /* eslint-disable-line no-empty */
}
if (isEvtWithFiles(event)) {
dispatch("dragover", {
dragEvent: event,
});
}
return false;
}
function onDragLeaveCb(event) {
event.preventDefault();
stopPropagation(event);
// Only deactivate once the dropzone and all children have been left
const targets = dragTargetsRef.filter(
(target) => rootRef && rootRef.contains(target)
);
// Make sure to remove a target present multiple times only once
// (Firefox may fire dragenter/dragleave multiple times on the same element)
const targetIdx = targets.indexOf(event.target);
if (targetIdx !== -1) {
targets.splice(targetIdx, 1);
}
dragTargetsRef = targets;
if (targets.length > 0) {
return;
}
state.isDragActive = false;
state.draggedFiles = [];
if (isEvtWithFiles(event)) {
dispatch("dragleave", {
dragEvent: event,
});
}
}
function onDropCb(event) {
event.preventDefault();
stopPropagation(event);
dragTargetsRef = [];
if (isEvtWithFiles(event)) {
Promise.resolve(getFilesFromEvent(event)).then((files) => {
if (isPropagationStopped(event) && !noDragEventsBubbling) {
return;
}
const acceptedFiles = [];
const fileRejections = [];
files.forEach((file) => {
const [accepted, acceptError] = fileAccepted(file, accept);
const [sizeMatch, sizeError] = fileMatchSize(file, minSize, maxSize);
if (accepted && sizeMatch) {
acceptedFiles.push(file);
} else {
const errors = [acceptError, sizeError].filter((e) => e);
fileRejections.push({ file, errors });
}
});
if (!multiple && acceptedFiles.length > 1) {
// Reject everything and empty accepted files
acceptedFiles.forEach((file) => {
fileRejections.push({ file, errors: [TOO_MANY_FILES_REJECTION] });
});
acceptedFiles.splice(0);
}
state.acceptedFiles = acceptedFiles;
state.fileRejections = fileRejections;
dispatch("drop", {
acceptedFiles,
fileRejections,
event,
});
if (fileRejections.length > 0) {
dispatch("droprejected", {
fileRejections,
event,
});
}
if (acceptedFiles.length > 0) {
dispatch("dropaccepted", {
acceptedFiles,
event,
});
}
});
}
resetState();
}
function composeHandler(fn) {
return disabled ? null : fn;
}
function composeKeyboardHandler(fn) {
return noKeyboard ? null : composeHandler(fn);
}
function composeDragHandler(fn) {
return noDrag ? null : composeHandler(fn);
}
function stopPropagation(event) {
if (noDragEventsBubbling) {
event.stopPropagation();
}
}
let dragTargetsRef = [];
function onDocumentDrop(event) {
if (rootRef && rootRef.contains(event.target)) {
// If we intercepted an event for our instance, let it propagate down to the instance's onDrop handler
return;
}
event.preventDefault();
dragTargetsRef = [];
}
// Update file dialog active state when the window is focused on
function onWindowFocus() {
// Execute the timeout only if the file dialog is opened in the browser
if (state.isFileDialogActive) {
setTimeout(() => {
if (inputRef) {
const { files } = inputRef;
if (!files.length) {
state.isFileDialogActive = false;
dispatch("filedialogcancel");
}
}
}, 300);
}
}
onMount(() => {
window.addEventListener("focus", onWindowFocus, false);
if (preventDropOnDocument) {
document.addEventListener("dragover", onDocumentDragOver, false);
document.addEventListener("drop", onDocumentDrop, false);
}
});
onDestroy(() => {
window.removeEventListener("focus", onWindowFocus, false);
if (preventDropOnDocument) {
document.removeEventListener("dragover", onDocumentDragOver);
document.removeEventListener("drop", onDocumentDrop);
}
});
function onInputElementClick(event) {
event.stopPropagation();
}
</script>
<div
bind:this={rootRef}
tabindex="0"
class="{disableDefaultStyles ? '' : 'dropzone'}
{containerClasses}"
style={containerStyles}
on:keydown={composeKeyboardHandler(onKeyDownCb)}
on:focus={composeKeyboardHandler(onFocusCb)}
on:blur={composeKeyboardHandler(onBlurCb)}
on:click={composeHandler(onClickCb)}
on:dragenter={composeDragHandler(onDragEnterCb)}
on:dragover={composeDragHandler(onDragOverCb)}
on:dragleave={composeDragHandler(onDragLeaveCb)}
on:drop={composeDragHandler(onDropCb)}
>
<input
{accept}
{multiple}
type="file"
autocomplete="off"
tabindex="-1"
on:change={onDropCb}
on:click={onInputElementClick}
bind:this={inputRef}
style="display: none;"
/>
<slot>
<p>Drag 'n' drop some files here, or click to select files</p>
</slot>
</div>
<style>
.dropzone {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
border-width: 2px;
outline: solid thin black;
width: 300px;
}
.dropzone:focus {
border-color: #2196f3;
}
</style>

View File

@ -0,0 +1,33 @@
/**
* Check if the provided file type should be accepted by the input with accept attribute.
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input#attr-accept
*
* Inspired by https://github.com/enyo/dropzone
*
* @param file {File} https://developer.mozilla.org/en-US/docs/Web/API/File
* @param acceptedFiles {string}
* @returns {boolean}
*/
export default function(file, acceptedFiles) {
if (file && acceptedFiles) {
const acceptedFilesArray = Array.isArray(acceptedFiles)
? acceptedFiles
: acceptedFiles.split(",");
const fileName = file.name || "";
const mimeType = (file.type || "").toLowerCase();
const baseMimeType = mimeType.replace(/\/.*$/, "");
return acceptedFilesArray.some((type) => {
const validType = type.trim().toLowerCase();
if (validType.charAt(0) === ".") {
return fileName.toLowerCase().endsWith(validType);
} else if (validType.endsWith("/*")) {
// This is something like a image/* mime type
return baseMimeType === validType.replace(/\/.*$/, "");
}
return mimeType === validType;
});
}
return true;
}

View File

@ -0,0 +1,151 @@
import accepts from "./attr-accept";
// Error codes
export const FILE_INVALID_TYPE = "file-invalid-type";
export const FILE_TOO_LARGE = "file-too-large";
export const FILE_TOO_SMALL = "file-too-small";
export const TOO_MANY_FILES = "too-many-files";
// File Errors
export const getInvalidTypeRejectionErr = (accept) => {
accept = Array.isArray(accept) && accept.length === 1 ? accept[0] : accept;
const messageSuffix = Array.isArray(accept)
? `one of ${accept.join(", ")}`
: accept;
return {
code: FILE_INVALID_TYPE,
message: `File type must be ${messageSuffix}`,
};
};
export const getTooLargeRejectionErr = (maxSize) => {
return {
code: FILE_TOO_LARGE,
message: `File is larger than ${maxSize} bytes`,
};
};
export const getTooSmallRejectionErr = (minSize) => {
return {
code: FILE_TOO_SMALL,
message: `File is smaller than ${minSize} bytes`,
};
};
export const TOO_MANY_FILES_REJECTION = {
code: TOO_MANY_FILES,
message: "Too many files",
};
// Firefox versions prior to 53 return a bogus MIME type for every file drag, so dragovers with
// that MIME type will always be accepted
export function fileAccepted(file, accept) {
const isAcceptable =
file.type === "application/x-moz-file" || accepts(file, accept);
return [
isAcceptable,
isAcceptable ? null : getInvalidTypeRejectionErr(accept),
];
}
export function fileMatchSize(file, minSize, maxSize) {
if (isDefined(file.size)) {
if (isDefined(minSize) && isDefined(maxSize)) {
if (file.size > maxSize) return [false, getTooLargeRejectionErr(maxSize)];
if (file.size < minSize) return [false, getTooSmallRejectionErr(minSize)];
} else if (isDefined(minSize) && file.size < minSize)
return [false, getTooSmallRejectionErr(minSize)];
else if (isDefined(maxSize) && file.size > maxSize)
return [false, getTooLargeRejectionErr(maxSize)];
}
return [true, null];
}
function isDefined(value) {
return value !== undefined && value !== null;
}
export function allFilesAccepted({
files,
accept,
minSize,
maxSize,
multiple,
}) {
if (!multiple && files.length > 1) {
return false;
}
return files.every((file) => {
const [accepted] = fileAccepted(file, accept);
const [sizeMatch] = fileMatchSize(file, minSize, maxSize);
return accepted && sizeMatch;
});
}
// React's synthetic events has event.isPropagationStopped,
// but to remain compatibility with other libs (Preact) fall back
// to check event.cancelBubble
export function isPropagationStopped(event) {
if (typeof event.isPropagationStopped === "function") {
return event.isPropagationStopped();
} else if (typeof event.cancelBubble !== "undefined") {
return event.cancelBubble;
}
return false;
}
export function isEvtWithFiles(event) {
if (!event.dataTransfer) {
return !!event.target && !!event.target.files;
}
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
// https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
return Array.prototype.some.call(
event.dataTransfer.types,
(type) => type === "Files" || type === "application/x-moz-file"
);
}
export function isKindFile(item) {
return typeof item === "object" && item !== null && item.kind === "file";
}
// allow the entire document to be a drag target
export function onDocumentDragOver(event) {
event.preventDefault();
}
function isIe(userAgent) {
return (
userAgent.indexOf("MSIE") !== -1 || userAgent.indexOf("Trident/") !== -1
);
}
function isEdge(userAgent) {
return userAgent.indexOf("Edge/") !== -1;
}
export function isIeOrEdge(userAgent = window.navigator.userAgent) {
return isIe(userAgent) || isEdge(userAgent);
}
/**
* This is intended to be used to compose event handlers
* They are executed in order until one of them calls `event.isPropagationStopped()`.
* Note that the check is done on the first invoke too,
* meaning that if propagation was stopped before invoking the fns,
* no handlers will be executed.
*
* @param {Function} fns the event hanlder functions
* @return {Function} the event handler to add to an element
*/
export function composeEventHandlers(...fns) {
return (event, ...args) =>
fns.some((fn) => {
if (!isPropagationStopped(event) && fn) {
fn(event, ...args);
}
return isPropagationStopped(event);
});
}

8
view/src/components/Toast/Toast.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
interface Toast {
type: string;
msg: string;
time: number;
res?: () => void;
rej?: () => void;
}

View File

@ -0,0 +1,65 @@
<script lang="ts">
import { fly } from "svelte/transition";
export let msg;
export let time;
export let type;
export let res;
export let rej;
</script>
<div transition:fly={{ duration: 500, x: -50 }} class={`wrapper type-${type}`}>
<p>{@html msg}</p>
{#if type === "confirm"}
<div class="button-wrapper">
<button on:click={res}>okay</button>
<button on:click={rej}>nope</button>
</div>
{/if}
</div>
<style>
.wrapper {
outline: solid thin black;
padding-top: 2.5px;
margin: 5px;
}
.wrapper.type-warn {
background-color: rgb(255, 255, 132);
}
.wrapper.type-warn > p::before,
.wrapper.type-warn > p::after {
content: " ! ";
font-weight: bold;
}
.button-wrapper {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
}
.button-wrapper > button {
border-radius: 0px;
background: none;
height: 100%;
padding: 0px;
border: none;
color: black;
outline: solid thin black;
}
.button-wrapper > button:first-child {
background-color: rgb(131, 255, 131);
}
.button-wrapper > button:last-child {
background-color: rgb(255, 126, 126);
}
p {
box-sizing: border-box;
margin: 10px;
}
</style>

View File

@ -0,0 +1,19 @@
<script lang="ts">
import Toast from "./Toast.svelte";
import toasts from "./store";
</script>
<div class="toast-wrapper">
{#each $toasts as toast}
<Toast {...toast} />
{/each}
</div>
<style>
.toast-wrapper {
padding: 1em;
position: fixed;
left: 0px;
bottom: 0px;
}
</style>

View File

@ -0,0 +1,44 @@
import store from "./store";
function add(type, msg, duration) {
let prom: Promise<boolean>;
const toast: Toast = {
type,
msg,
time: duration
}
if (type === "confirm") {
prom = new Promise((res, rej) => {
toast.res = () => {
store.update(toasts => toasts.filter(t => t !== toast));
res(true);
};
toast.rej = () => {
store.update(toasts => toasts.filter(t => t !== toast));
res(false);
};
})
}
store.update(toasts => [...toasts, toast])
setTimeout(() => {
store.update(toasts => toasts.filter(t => t !== toast));
if (type === "confirm") {
toast.rej();
}
}, duration);
return prom;
}
export default {
info: msg => add("info", msg, 3000),
warn: msg => add("warn", msg, 3000),
error: msg => add("error", msg, 3000),
confirm: msg => add("confirm", msg, 10000),
}

View File

@ -0,0 +1,3 @@
import { writable } from "svelte/store";
export default writable<Toast[]>([]);

View File

@ -0,0 +1,13 @@
<svg
width="82"
height="82"
viewBox="0 0 82 82"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M41 0V81.5M81.75 40.75L0.25 40.75"
stroke="black"
stroke-width="0.75px"
/>
</svg>

After

Width:  |  Height:  |  Size: 214 B

7
view/src/main.ts Executable file
View File

@ -0,0 +1,7 @@
import App from './App.svelte';
const app = new App({
target: document.body
});
export default app;

2
view/src/routes/index.ts Executable file
View File

@ -0,0 +1,2 @@
export { default as main } from "./main.svelte"
export { default as list } from "./list.svelte"

View File

@ -0,0 +1,24 @@
<script lang="ts">
import { store as imageStore } from "../stores/images";
let images = [];
const urlCreator = window.URL || window.webkitURL;
$: if ($imageStore.length) {
images = $imageStore.map((img) => {
const blob = new Blob([img.data], { type: img.type });
const imageUrl = urlCreator.createObjectURL(blob);
return {
...img,
imageUrl,
};
});
}
</script>
{#each images as img}
<p>{img.filename}</p>
<img src={img.imageUrl} />
{/each}
<h3>List</h3>

168
view/src/routes/main.svelte Executable file
View File

@ -0,0 +1,168 @@
<script lang="ts">
import DropZone from "../components/DropZone/index.svelte";
import AnimatedNumber from "../components/AnimatedNumber.svelte";
import Cross from "../icons/Cross.svelte";
import Toast from "../components/Toast";
import { route as currentRoute, images } from "../stores";
let acceptedFiles: File[] = [];
let hovering = false;
async function handleFilesSelect(e) {
if (e.detail.acceptedFiles) {
const addedFiles: File[] = e.detail.acceptedFiles;
const newFiles: File[] = [];
const dupes: File[] = [];
addedFiles.forEach((f) => {
const isNew = !acceptedFiles.find((_f) => {
return (
_f.lastModified === f.lastModified &&
_f.name === f.name &&
_f.type === f.type &&
_f.size === f.size
);
});
if (isNew) {
newFiles.push(f);
} else {
dupes.push(f);
}
});
acceptedFiles = [...acceptedFiles, ...newFiles];
if (dupes.length) {
const loadDupes = await Toast.confirm(
`Add <b> ${dupes.length}</b> duplicate file${
dupes.length > 1 ? "s" : ""
}?`
);
if (loadDupes) {
acceptedFiles = [...acceptedFiles, ...dupes];
}
}
}
hovering = false;
}
async function handleLoadingImages(e) {
e.preventDefault();
e.stopImmediatePropagation();
e.stopPropagation();
const img = await Promise.all(
acceptedFiles.map(async (f) => {
return {
name: f.name,
filename: f.name,
type: f.type,
lastModified: f.lastModified,
data: await f.arrayBuffer(),
};
})
);
images.add(img);
await currentRoute.set("list");
}
function handleClearingImages(e) {
e.preventDefault();
e.stopImmediatePropagation();
e.stopPropagation();
acceptedFiles = [];
}
</script>
<div class="wrapper">
<DropZone
accept="image/*"
on:drop={handleFilesSelect}
on:dragenter={() => (hovering = true)}
on:dragleave={() => (hovering = false)}
on:dragenter={() => (hovering = false)}
>
<div class="inner-wrapper">
<div class="icon-wrapper" class:hovering>
<Cross />
</div>
<p>Drop an image here to get started</p>
<div class="button-wrapper" class:visible={acceptedFiles.length > 0}>
<button on:click={handleLoadingImages}>
load <b><AnimatedNumber num={acceptedFiles.length} /></b>
</button>
<button on:click={handleClearingImages}> clear </button>
</div>
</div>
</DropZone>
</div>
<style>
.wrapper {
display: grid;
place-items: center;
height: 100%;
}
.inner-wrapper {
position: relative;
display: flex;
align-items: center;
padding: 20px;
}
p {
font-family: Roboto;
margin-left: 20px;
font-weight: 500;
text-align: left;
}
.button-wrapper > button {
border-radius: 0px;
background: none;
height: 100%;
border: none;
color: black;
outline: solid thin black;
}
.button-wrapper > button:first-child {
background-color: rgb(131, 255, 131);
}
.button-wrapper > button:last-child {
background-color: rgb(255, 126, 126);
}
.button-wrapper {
position: absolute;
bottom: -1px;
left: 0px;
width: 100%;
display: grid;
transform: translateY(100%);
pointer-events: none;
opacity: 0;
transition: opacity 0.3s cubic-bezier(0.57, 0.21, 0.69, 1.25);
grid-template-columns: 1fr 1fr;
}
.icon-wrapper {
transform: rotate(0deg);
transition: transform 0.3s ease;
}
.icon-wrapper.hovering {
transform: rotate(45deg);
}
.button-wrapper.visible {
opacity: 1;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,7 @@
import { writable } from "svelte/store";
const r = "route" in localStorage ? localStorage.getItem("route"):"main";
const store = writable<string>(r);
export default store;

63
view/src/stores/images.ts Normal file
View File

@ -0,0 +1,63 @@
import { writable } from "svelte/store";
import { openDB, DBSchema } from 'idb';
interface Image {
name: string,
filename: string
lastModified: number
type: string
data: ArrayBuffer
}
interface ImageDB extends DBSchema {
images: {
value: Image;
key: string;
indexes: { 'filename': string };
};
}
const db = openDB<ImageDB>('Images', 1, {
upgrade(db) {
// Create a store of objects
const store = db.createObjectStore('images', {
// The 'id' property of the object will be the key.
keyPath: 'id',
// If it isn't explicitly set, create a value by auto incrementing.
autoIncrement: true,
});
// Create an index on the 'date' property of the objects.
store.createIndex('filename', 'filename');
},
});
const store = writable<Image[]>([]);
async function add(img: Image | Image[]) {
const images = Array.isArray(img) ? [...img] : [img];
const tx = (await db).transaction('images', 'readwrite');
await Promise.all(images.map(img => tx.store.add(img)));
await tx.done;
}
async function get(name: string): Promise<Image> {
return new Promise((res, rej) => {
res({} as Image);
})
}
async function getAll(): Promise<Image[]> {
return (await db).getAll("images");
}
(async () => store.set(await getAll()))()
export { store, add, get, getAll };

3
view/src/stores/index.ts Normal file
View File

@ -0,0 +1,3 @@
import * as images from "./images";
export { images }
export { default as route } from "./route";

18
view/src/stores/route.ts Normal file
View File

@ -0,0 +1,18 @@
import { writable } from "svelte/store";
let r = "main";
if (window.location.hash.length) {
r = window.location.hash.replace("#", "");
} else {
r = "main";
}
const store = writable<string>(r);
store.subscribe(s => {
if (s === "main") s = ""
window.location.hash = s;
})
export default store;

6
view/tsconfig.json Executable file
View File

@ -0,0 +1,6 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
}