Initial commit. Files are uploaded to the filesystem.

This commit is contained in:
ThePendulum 2025-09-25 06:19:37 +02:00
commit 745b00dcdc
64 changed files with 9572 additions and 0 deletions

14
.editorconfig Executable file
View File

@ -0,0 +1,14 @@
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
# Matches multiple files with brace expansion notation
# Set default charset
[*.js]
charset = utf-8

12
.eslintrc.cjs Normal file
View File

@ -0,0 +1,12 @@
module.exports = {
root: true,
env: { browser: false, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: [],
rules: {},
}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
config/*
uploads/full/*
uploads/thumbs/*
uploads/temp/*
node_modules

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v22.19.0

2
README.md Normal file
View File

@ -0,0 +1,2 @@
# Pubload
Straight-forward self-hosted file sharing

View File

@ -0,0 +1,10 @@
@custom-media --small-60 (max-width: 350px);
@custom-media --small-50 (max-width: 410px);
@custom-media --small-40 (max-width: 480px);
@custom-media --small-30 (max-width: 540px);
@custom-media --small-20 (max-width: 650px);
@custom-media --small-15 (max-width: 720px);
@custom-media --small-10 (max-width: 768px);
@custom-media --small (max-width: 900px);
@custom-media --compact (max-width: 1200px);
@custom-media --big (max-width: 1500px);

6
assets/css/code.css Normal file
View File

@ -0,0 +1,6 @@
code {
font-family: monospace;
background-color: #eaeaea;
padding: 3px 5px;
border-radius: 4px;
}

48
assets/css/forms.css Normal file
View File

@ -0,0 +1,48 @@
.form {
width: 100%;
max-width: 30rem;
display: flex;
flex-direction: column;
}
.form-section {
margin-bottom: 1rem;
}
.form-row {
display: flex;
flex-wrap: wrap;
margin-bottom: .5rem;
.input {
flex-grow: 1;
}
}
.form-column {
display: flex;
flex-grow: 1;
flex-direction: column;
min-width: 10rem;
}
.form-actions {
display: flex;
margin-bottom: .5rem;
justify-content: flex-end;
margin-top: .5rem;
}
.form-heading {
color: var(--primary-light-10);
margin: 0 0 .75rem 0;
}
.form-error {
background: var(--error);
color: var(--text-light);
padding: 1rem;
border-radius: .25rem;
text-align: center;
font-weight: bold;
}

297
assets/css/inputs.css Normal file
View File

@ -0,0 +1,297 @@
.input {
box-sizing: border-box;
padding: .5rem .75rem;
font-size: 1rem;
flex-basis: 0;
color: inherit;
border: solid 1px var(--glass-weak-30);
border-radius: .25rem;
background: var(--background-base-10);
font: inherit;
&:focus {
outline: none;
border-color: var(--primary-light-10);
}
}
.input-inline {
border-radius: 0;
border: none;
border-bottom: solid 1px var(--grey-light-30);
background: var(--background);
}
.button {
display: inline-flex;
flex-shrink: 0;
align-items: stretch;
box-sizing: border-box;
padding: .5rem;
border: none;
border-radius: .25rem;
background: var(--background);
box-shadow: 0 0 3px var(--shadow-weak-30);
color: var(--glass);
font-size: .9rem;
font-weight: bold;
.icon {
height: auto;
padding: 0 .75rem 0 .25rem;
fill: var(--glass);
}
&:hover {
cursor: pointer;
background: var(--primary);
color: var(--text-light);
.icon {
fill: var(--text-light);
}
}
&:focus {
outline: none;
}
}
.button-label {
margin-right: .25rem;
}
.button-submit {
background: var(--primary);
color: var(--text-light);
justify-content: center;
&:hover:not(:disabled) {
background: var(--primary-dark-10);
}
&:disabled {
background: var(--glass);
}
}
.button-primary {
background: var(--primary);
color: var(--text-light);
justify-content: center;
&:hover:not(:disabled) {
background: var(--primary-dark-10);
}
&:disabled {
background: var(--glass);
}
}
.button-inline {
border: none;
background: none;
color: var(--grey-dark-20);
padding: 0 0 .5rem 0;
font-size: 1rem;
font-weight: bold;
.icon {
fill: var(--grey-dark-20);
padding-left: none;
}
&:hover {
cursor: pointer;
background: none;
color: var(--primary);
.icon {
fill: var(--primary);
}
}
&:focus {
outline: none;
}
}
.button-cancel {
background: none;
color: var(--glass);
font-weight: normal;
&:hover:not(:disabled) {
color: var(--error);
cursor: pointer;
}
&:disabled {
color: var(--glass);
}
}
.radio {
margin: 0 .5rem 0 0;
}
.filter-section {
width: 100%;
margin-bottom: .25rem;
}
.label-values {
flex-shrink: 0;
font-weight: normal;
}
.filter-split {
display: flex;
align-items: center;
}
.toggle-container,
.range-container {
display: flex;
flex-grow: 1;
align-items: center;
padding: .5rem 0;
&.on {
.toggle-label.on {
color: var(--enabled);
.icon {
fill: var(--enabled);
}
}
.toggle {
background-color: var(--enabled-background);
&::-webkit-slider-thumb {
background: var(--enabled);
}
&::-moz-range-thumb {
background: var(--enabled);
}
}
}
&.off {
.toggle-label.off {
color: var(--disabled);
.icon {
fill: var(--disabled);
}
}
.toggle {
background-color: var(--disabled-background);
&::-webkit-slider-thumb {
background: var(--disabled);
}
&::-moz-range-thumb {
background: var(--disabled);
}
}
}
}
.toggle-label {
display: inline-flex;
justify-content: center;
min-width: 1.5rem;
flex-shrink: 0;
padding: 0 .5rem;
color: var(--glass);
font-weight: bold;
font-size: .9rem;
&.on {
text-align: right;
}
.icon {
fill: var(--glass);
}
&:hover {
cursor: pointer;
&.on {
color: var(--enabled);
.icon {
fill: var(--enabled);
}
}
&.off {
color: var(--disabled);
.icon {
fill: var(--disabled);
}
}
}
}
.filter-label {
display: flex;
justify-content: space-between;
padding: .5rem 0 .25rem .25rem;
color: var(--glass);
font-weight: bold;
font-size: .9rem;
.label {
width: 100%;
display: flex;
align-items: center;
text-transform: capitalize;
}
.checkbox {
margin: 0 .75rem 0 0;
}
.icon {
margin: 0 .5rem 0 0;
}
}
.toggle {
width: 0;
flex-grow: 1;
height: 1.25rem;
appearance: none;
border-radius: 1rem;
background-color: var(--glass-weak-40);
background-image: radial-gradient(circle, var(--glass-weak-10) .3rem, transparent calc(.3rem + 1px));
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
background: var(--disabled-handle);
width: 1.25rem;
height: 1.25rem;
border-radius: .625rem;
box-shadow: 0 0 3px var(--shadow-weak-10);
}
&::-moz-range-thumb {
appearance: none;
background: var(--disabled-handle);
width: 1.25rem;
height: 1.25rem;
border: none;
border-radius: .625rem;
box-shadow: 0 0 3px var(--shadow-weak-10);
}
}

3
assets/css/links.css Normal file
View File

@ -0,0 +1,3 @@
a {
text-decoration: none;
}

21
assets/css/markdown.css Normal file
View File

@ -0,0 +1,21 @@
.markdown-body {
margin: 0 auto;
max-width: 50rem;
flex-grow: 1;
padding: 1rem;
line-height: 1.5;
text-align: justify;
& h1 {
margin: 0;
}
& h2 {
color: var(--primary);
margin: 1rem 0 0 0;
}
& p {
margin: 0;
}
}

7
assets/css/reset.css Normal file
View File

@ -0,0 +1,7 @@
body {
margin: 0;
font-family: sans-serif;
}
* {
box-sizing: border-box;
}

67
assets/css/states.css Executable file
View File

@ -0,0 +1,67 @@
.noselect {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.nolist {
list-style: none;
padding: 0;
margin: 0;
li {
display: inline-block;
padding: 0;
margin: 0;
}
}
.nolink {
display: inline-block;
color: inherit;
text-decoration: none;
}
.nolink-active {
display: inline-block;
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.nobutton {
background: none;
border: none;
font-size: 1rem;
padding: 0;
}
.nobar {
scrollbar-width: none;
-mis-overflow-style: none;
&::-webkit-scrollbar {
background: transparent;
width: 0px;
height: 0px;
}
}
.ellipsis {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.noshrink {
flex-shrink: 0;
}
.capitalize {
text-transform: capitalize;
}

34
assets/css/style.css Normal file
View File

@ -0,0 +1,34 @@
@import 'reset';
@import 'links';
@import 'code';
@import 'theme';
@import 'states';
@import 'inputs';
@import 'forms';
@import 'markdown';
@import 'tooltip';
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
}
.link {
color: var(--link);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.heading {
margin: 0 0 1rem 0;
color: var(--primary);
}

132
assets/css/theme.css Normal file
View File

@ -0,0 +1,132 @@
:root {
--primary-dark-10: #e54485;
--primary: #f65596;
--primary-light-10: #f075a6;
--primary-light-20: #f2a6c4;
--primary-light-30: #f7c9dc;
--primary: #D81159;
--primary-dark-10: #8F2D56;
--secondary: #73D2DE;
--secondary-dark-10: 218380;
--grey-dark-50: #111;
--grey-dark-40: #222;
--grey-dark-30: #444;
--grey-dark-20: #666;
--grey-dark-10: #888;
--grey: #aaa;
--grey-light-10: #bbb;
--grey-light-20: #ccc;
--grey-light-30: #ddd;
--grey-light-40: #eee;
--grey-light-50: #fafafa;
--grey-light-60: #fcfcfc;
--background-dark-20: #eee;
--background-dark-10: #f8f8f8;
--background: #fff;
--background-base: #fff;
--background-base-10: #fafafa;
--background-base-20: #f0f0f0;
--background-level-10: #fff;
--background-level-20: #eee;
--background-level-30: #eee;
--background-dim: var(--shadow-weak-10);
--background-error: rgba(255, 0, 0, .1);
--shadow-weak-50: rgba(0, 0, 0, .02);
--shadow-weak-40: rgba(0, 0, 0, .05);
--shadow-weak-30: rgba(0, 0, 0, .1);
--shadow-weak-20: rgba(0, 0, 0, .2);
--shadow-weak-10: rgba(0, 0, 0, .35);
--shadow: rgba(0, 0, 0, .5);
--shadow-strong-10: rgba(0, 0, 0, .6);
--shadow-strong-20: rgba(0, 0, 0, .75);
--shadow-strong-30: rgba(0, 0, 0, .9);
--highlight-weak-40: rgba(255, 255, 255, .05);
--highlight-weak-30: rgba(255, 255, 255, .1);
--highlight-weak-20: rgba(255, 255, 255, .2);
--highlight-weak-10: rgba(255, 255, 255, .35);
--highlight: rgba(255, 255, 255, .5);
--highlight-strong-10: rgba(255, 255, 255, .6);
--highlight-strong-20: rgba(255, 255, 255, .75);
--highlight-strong-30: rgba(255, 255, 255, .9);
--glass-weak-50: rgba(0, 0, 0, .02);
--glass-weak-40: rgba(0, 0, 0, .05);
--glass-weak-30: rgba(0, 0, 0, .1);
--glass-weak-20: rgba(0, 0, 0, .2);
--glass-weak-10: rgba(0, 0, 0, .35);
--glass: rgba(0, 0, 0, .5);
--glass-strong-10: rgba(0, 0, 0, .6);
--glass-strong-20: rgba(0, 0, 0, .75);
--glass-strong-30: rgba(0, 0, 0, .9);
--text: #222;
--text-light: #fff;
/* --link: #48f; */
--link: var(--primary);
--male: #0af;
--female: #f0a;
--enabled: #5c2;
--enabled-background: rgba(0, 255, 0, .1);
--disabled: #c20;
--disabled-background: rgba(255, 0, 0, .1);
--disabled-handle: var(--grey-light-10);
--error: #f66;
--alert: #f00;
--warn: #e80;
--success: #5c2;
--notice: #25c;
--approve: #3a1;
--reject: #a22;
--gold: #d5b522;
}
.dark {
--background-dark-20: #000;
--background-dark-10: #111;
--background: #222;
--background-base: #252525;
--background-base-10: #1a1a1a;
--background-base-20: #050505;
--background-level-10: #fff;
--background-level-20: #eee;
--background-level-30: #eee;
--background-dim: var(--shadow-weak-10);
--text: #fcfcfc;
--glass-weak-50: rgba(255, 255, 255, .02);
--glass-weak-40: rgba(255, 255, 255, .05);
--glass-weak-30: rgba(255, 255, 255, .1);
--glass-weak-20: rgba(255, 255, 255, .2);
--glass-weak-10: rgba(255, 255, 255, .35);
--glass: rgba(255, 255, 255, .5);
--glass-strong-10: rgba(255, 255, 255, .6);
--glass-strong-20: rgba(255, 255, 255, .75);
--glass-strong-30: rgba(255, 255, 255, .9);
--grey-dark-50: #101010;
--grey-dark-40: #1f1f1f;
--grey-dark-30: #3c3c3c;
--grey-dark-20: #606060;
--grey-dark-10: #808080;
--grey: #aaa;
--grey-light-10: #bbb;
--grey-light-20: #ccc;
--grey-light-30: #ddd;
--grey-light-40: #eee;
--grey-light-50: #fafafa;
--grey-light-60: #fcfcfc;
}

205
assets/css/tooltip.css Normal file
View File

@ -0,0 +1,205 @@
/* Content */
.v-popper__popper {
z-index: 10000;
top: 0;
left: 0;
outline: none;
max-width: 100%;
}
.v-popper__wrapper {
margin: 0 .25rem .25rem .25rem; /* arrow provides top clearance */
}
.v-popper__popper.v-popper__popper--hidden {
visibility: hidden;
opacity: 0;
transition: opacity .15s, visibility .15s;
pointer-events: none;
}
.v-popper__popper.v-popper__popper--shown {
visibility: visible;
opacity: 1;
transition: opacity .15s;
}
.v-popper__popper.v-popper__popper--skip-transition,
.v-popper__popper.v-popper__popper--skip-transition > .v-popper__wrapper {
transition: none !important;
}
.v-popper__backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: none;
}
.v-popper__inner {
position: relative;
box-sizing: border-box;
overflow-y: auto;
}
.v-popper__inner > div {
position: relative;
z-index: 1;
max-width: inherit;
max-height: inherit;
}
.v-popper__arrow-container {
position: absolute;
width: 10px;
height: 10px;
}
.v-popper__popper--arrow-overflow .v-popper__arrow-container,
.v-popper__popper--no-positioning .v-popper__arrow-container {
display: none;
}
.v-popper__arrow-inner,
.v-popper__arrow-outer {
border-style: solid;
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
}
.v-popper__arrow-inner {
visibility: hidden;
border-width: 7px;
}
.v-popper__arrow-outer {
border-width: 6px;
}
.v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner {
left: -2px;
}
.v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-outer,
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-outer {
left: -1px;
}
.v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-outer {
border-bottom-width: 0;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
}
.v-popper__popper[data-popper-placement^="top"] .v-popper__arrow-inner {
top: -2px;
}
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-container {
top: 0;
}
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-outer {
border-top-width: 0;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-top-color: transparent !important;
}
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-inner {
top: -4px;
}
.v-popper__popper[data-popper-placement^="bottom"] .v-popper__arrow-outer {
top: -6px;
}
.v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-inner {
top: -2px;
}
.v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-outer,
.v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-outer {
top: -1px;
}
.v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-outer {
border-left-width: 0;
border-left-color: transparent !important;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
}
.v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-inner {
left: -4px;
}
.v-popper__popper[data-popper-placement^="right"] .v-popper__arrow-outer {
left: -6px;
}
.v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-container {
right: -10px;
}
.v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-inner,
.v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-outer {
border-right-width: 0;
border-top-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
}
.v-popper__popper[data-popper-placement^="left"] .v-popper__arrow-inner {
left: -2px;
}
/* Tooltip */
.v-popper--theme-tooltip .v-popper__inner {
background: rgba(0, 0, 0, .8);
color: white;
border-radius: 6px;
padding: 7px 12px 6px;
}
.v-popper--theme-tooltip .v-popper__arrow-outer {
border-color: rgba(0, 0, 0, .8);
}
/* Dropdown */
.v-popper--theme-dropdown .v-popper__inner {
background: var(--background);
color: var(--text);
border-radius: 6px;
border: solid 1px var(--glass-weak-40);
box-shadow: 0 6px 30px rgba(0, 0, 0, .1);
}
.v-popper--theme-dropdown .v-popper__arrow-inner {
visibility: visible;
border-color: var(--background);
}
.v-popper--theme-dropdown .v-popper__arrow-outer {
border-color: var(--glass-weak-40);
}
.resize-observer {
width: 0;
height: 0;
overflow: hidden;
}

36
assets/img/logo.svg Normal file
View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="175" height="175" fill="none" version="1.1" viewBox="0 0 175 175" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<metadata>
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<defs>
<linearGradient id="linearGradient880" x1="108.64" x2="115.51" y1="88.726" y2="136.2" gradientTransform="matrix(1.0498 0 0 1.0498 -2.9171 -2.9658)" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffea83" offset="0"/>
<stop stop-color="#FFDD35" offset=".083333"/>
<stop stop-color="#FFA800" offset="1"/>
</linearGradient>
<linearGradient id="paint2_linear" x1="48.975" x2="61.299" y1="3.9232" y2="158.04" gradientTransform="translate(-2.832e-5)" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83" offset="0"/>
<stop stop-color="#FFDD35" offset=".083333"/>
<stop stop-color="#FFA800" offset="1"/>
</linearGradient>
<linearGradient id="paint0_linear-6" x1="-1.4492" x2="116.62" y1="-5.8123" y2="137.08" gradientTransform="translate(-2.832e-5)" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF" offset="0"/>
<stop stop-color="#BD34FE" offset="1"/>
</linearGradient>
</defs>
<circle cx="87.5" cy="87.5" r="87.5" fill="#c4c4c4"/>
<circle cx="87.5" cy="87.5" r="87.5" fill="url(#paint0_linear-6)"/>
<g transform="translate(632.92 54.355)" fill="#d38787" strokeWidth="1.0614">
<path d="m-549.75 68.457c-5.7533-3.1217-6.1166-5.2295-6.1166-35.489 0-30.458 0.35464-32.448 6.3339-35.54 3.9943-2.0655 24.279-2.2805 26.735-0.28333 0.89718 0.72974 6.7203 6.6637 12.94 13.187l11.309 11.86v19.575c0 18.473-0.12956 19.74-2.3011 22.5-4.0223 5.1136-7.558 5.8565-27.65 5.8099-14.15-0.03287-19.008-0.40294-21.25-1.6191zm42.473-6.3594c2.27-1.59 2.359-2.2909 2.359-18.575v-16.923h-6.9521c-12.443 0-16.4-4.0845-16.4-16.93v-7.4828h-8.9464c-6.7178 0-9.3619 0.41549-10.614 1.668-2.5031 2.5031-2.5031 55.724 0 58.228 2.4502 2.4502 37.058 2.4636 40.553 0.01609zm-1.8867-42.165c0-0.16422-2.8659-3.1346-6.3686-6.6008l-6.3686-6.3022v4.9328c0 6.3185 1.8955 8.2687 8.0366 8.2687 2.5854 0 4.7007-0.13434 4.7007-0.29859zm-57.57 44.279c-5.6185-3.0486-6.1166-5.593-6.1166-31.243 0-18.891 0.31331-24.063 1.6101-26.571 1.809-3.4981 6.5048-6.3339 10.489-6.3339 2.4847 0 2.5814 0.19984 1.541 3.1843-0.61054 1.7514-1.7457 3.1843-2.5226 3.1843-0.77686 0-2.1631 0.75059-3.0805 1.668-2.4923 2.4923-2.4923 47.244 0 49.736 0.91739 0.9174 2.3036 1.668 3.0805 1.668 0.77688 0 1.912 1.4329 2.5226 3.1843 1.0562 3.0298 0.97108 3.1822-1.7537 3.1418-1.575-0.02331-4.1713-0.75194-5.7694-1.6191zm-16.983-4.2458c-5.4392-2.9512-6.1166-5.9415-6.1166-26.997 0-15.096 0.345-19.878 1.6101-22.325 1.7476-3.3796 6.4758-6.3339 10.137-6.3339 1.8666 0 2.1789 0.44955 1.6594 2.3882-0.35184 1.3135-0.64655 2.7465-0.65453 3.1843-8e-3 0.43784-0.69682 0.79608-1.5308 0.79608-0.83399 0-2.2669 0.75059-3.1843 1.668-2.4767 2.4767-2.4767 38.768 0 41.244 0.91741 0.91739 2.2946 1.668 3.0605 1.668 1.196 0 2.6402 2.995 2.6871 5.5726 0.0241 1.3294-4.5804 0.80962-7.6676-0.8655z" style="mix-blend-mode:lighten"/>
<path d="m-552.2 68.911c-5.7533-3.1217-6.1166-5.2295-6.1166-35.489 0-30.458 0.35463-32.448 6.3339-35.54 3.9943-2.0655 24.279-2.2805 26.735-0.28333 0.89718 0.72974 6.7203 6.6637 12.94 13.187l11.309 11.86v19.575c0 18.473-0.12957 19.74-2.3011 22.5-4.0223 5.1136-7.558 5.8565-27.65 5.8099-14.15-0.03287-19.008-0.40294-21.25-1.6191zm42.473-6.3594c2.27-1.59 2.359-2.2909 2.359-18.575v-16.923h-6.952c-12.443 0-16.4-4.0845-16.4-16.93v-7.4828h-8.9464c-6.7179 0-9.3619 0.41549-10.614 1.668-2.5031 2.5031-2.5031 55.724 0 58.228 2.4502 2.4502 37.058 2.4636 40.553 0.01609zm-1.8867-42.165c0-0.16422-2.8659-3.1346-6.3686-6.6008l-6.3686-6.3022v4.9328c0 6.3185 1.8955 8.2688 8.0366 8.2688 2.5854 0 4.7007-0.13434 4.7007-0.29859zm-57.57 44.279c-5.6185-3.0486-6.1166-5.593-6.1166-31.243 0-18.891 0.31331-24.063 1.6101-26.571 1.809-3.4981 6.5048-6.3339 10.489-6.3339 2.4847 0 2.5814 0.19984 1.541 3.1843-0.61054 1.7514-1.7457 3.1843-2.5226 3.1843-0.77687 0-2.1631 0.75059-3.0805 1.668-2.4923 2.4923-2.4923 47.244 0 49.736 0.91741 0.91739 2.3036 1.668 3.0805 1.668 0.77686 0 1.912 1.4329 2.5226 3.1843 1.0562 3.0298 0.97107 3.1822-1.7537 3.1418-1.575-0.02331-4.1713-0.75194-5.7694-1.6191zm-16.983-4.2458c-5.4392-2.9512-6.1166-5.9415-6.1166-26.997 0-15.096 0.34502-19.878 1.6101-22.325 1.7476-3.3796 6.4758-6.3339 10.137-6.3339 1.8666 0 2.1789 0.44955 1.6594 2.3882-0.35182 1.3135-0.64653 2.7465-0.65452 3.1843-8e-3 0.43784-0.69683 0.79608-1.5308 0.79608-0.83397 0-2.2669 0.75059-3.1843 1.668-2.4767 2.4767-2.4767 38.768 0 41.245 0.9174 0.91739 2.2946 1.668 3.0605 1.668 1.196 0 2.6402 2.995 2.6871 5.5726 0.0241 1.3294-4.5804 0.80962-7.6676-0.8655z" fillOpacity=".47466" style="mix-blend-mode:lighten"/>
</g>
<path d="m128.48 88.913-24.027 4.6784c-0.39475 0.07685-0.68766 0.40944-0.71076 0.80849l-1.4782 24.805c-0.0347 0.58371 0.50497 1.0372 1.0792 0.90602l6.6886-1.5338c0.62676-0.14383 1.1916 0.40419 1.0635 1.0299l-1.9874 9.6702c-0.13438 0.65091 0.48084 1.2073 1.1202 1.0142l4.1322-1.2472c0.64041-0.19317 1.2556 0.36535 1.1202 1.0162l-3.158 15.191c-0.19842 0.95011 1.074 1.4677 1.6042 0.653l0.35485-0.54382 19.578-38.827c0.32755-0.64985-0.23727-1.391-0.95641-1.2535l-6.8849 1.3207c-0.6467 0.12389-1.1979-0.47453-1.0152-1.1034l4.4944-15.482c0.18266-0.63012-0.36955-1.2295-1.0173-1.1034z" fill="url(#linearGradient880)" strokeWidth="1.0498"/>
<rect x="3" y="3" width="169" height="169" rx="84.5" stroke="url(#paint2_linear)" strokeWidth="6" style="mix-blend-mode:soft-light"/>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

27
components/Link.vue Normal file
View File

@ -0,0 +1,27 @@
<template>
<a :class="{ active: isActive }">
<slot />
</a>
</template>
<script lang="ts" setup>
import { useAttrs, computed } from 'vue'
import { usePageContext } from '../renderer/usePageContext'
const pageContext = usePageContext()
const { href } = useAttrs() as { href: string }
const isActive = computed(() => {
const { urlPathname } = pageContext.value
return href === '/' ? urlPathname === href : urlPathname.startsWith(href)
})
</script>
<style scoped>
a {
padding: 2px 10px;
margin-left: -10px;
}
a.active {
background-color: #eee;
}
</style>

178
components/upload/drop.vue Normal file
View File

@ -0,0 +1,178 @@
<template>
<form
class="selector"
:class="{ dragging }"
@submit.prevent="upload"
>
<div
ref="dropzone"
class="dropzone"
@drop.prevent="dropFile"
@dragover="dragging = true"
@dragleave="dragging = false"
>
<input
type="file"
@change="selectFile"
>
Drop or paste your files here
</div>
<ul class="uploads nolist">
<li
v-for="upload in thumbs"
class="upload"
>
<img
v-if="upload.file.type.indexOf('image/') === 0"
:src="upload.thumb"
class="upload-thumb"
>
<video
v-if="upload.file.type.indexOf('video/') === 0"
class="upload-thumb"
autoplay
muted
loop
>
<source :src="upload.thumb">
</video>
<span class="upload-name ellipsis">{{ upload.file.name }}</span>
</li>
</ul>
<button
class="button button-primary submit"
>Upload</button>
<label>
<input
v-model="addToAlbum"
type="checkbox"
>Add to album
</label>
</form>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { post } from '#src/api.ts';
const dropzone = ref(null);
const dragging = ref(false);
const uploads = ref([]);
const thumbs = ref([]);
const addToAlbum = ref(true);
async function upload() {
console.log('UPLOAD!', uploads.value);
const form = new FormData();
uploads.value.forEach((file) => {
form.append('files', file);
});
form.append('options', JSON.stringify({
addToAlbum: addToAlbum.value,
}));
const res = await post('/files', form);
console.log(res);
}
function addFiles(newFiles) {
console.log('NEW FILES', newFiles);
uploads.value = uploads.value.concat(newFiles);
thumbs.value = thumbs.value.concat(newFiles.map((file) => ({
file,
thumb: URL.createObjectURL(file),
})));
}
function dropFile(event) {
dragging.value = false;
const newFiles = Array.from(event.dataTransfer.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile());
addFiles(newFiles);
}
function selectFile(event) {
const newFiles = Array.from(event.target.files);
addFiles(newFiles);
event.target.value = null;
}
onMounted(() => {
window.addEventListener('dragover', (event) => event.preventDefault());
window.addEventListener('drop', (event) => event.preventDefault());
});
</script>
<style scoped>
.selector {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.dropzone {
width: 100%;
height: 15rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 1rem;
border: solid 1px var(--glass-weak-20);
&.dragging {
background: var(--primary);
}
}
.uploads {
width: 100%;
display: grid;
align-items: center;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: .5rem;
}
.upload {
display: inline-flex;
flex-direction: column;
}
.upload-thumb {
width: 8rem;
height: 6rem;
object-fit: cover;
margin-bottom: .25rem;
}
.upload-name {
font-size: .8rem;
}
.submit {
font-size: 1.2rem;
padding: .75rem 1rem;
}
</style>

174
dist/api.js vendored Normal file
View File

@ -0,0 +1,174 @@
import { parse } from '@brillout/json-serializer/parse'; /* eslint-disable-line import/extensions */
import events from '#src/events';
const postHeaders = {
mode: 'cors',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
};
function getQuery(data) {
if (!data) {
return '';
}
const curatedQuery = Object.fromEntries(Object.entries(data).map(([key, value]) => (value === undefined ? null : [key, value])).filter(Boolean));
return `?${new URLSearchParams(curatedQuery).toString()}`; // recode so commas aren't encoded
}
function showFeedback(isSuccess, options = {}, errorMessage) {
if (!isSuccess && (typeof options.errorFeedback === 'string' || options.appendErrorMessage)) {
events.emit('feedback', {
type: 'error',
message: options.appendErrorMessage && errorMessage
? `${options.errorFeedback ? `${options.errorFeedback}: ` : ''}${errorMessage}`
: options.errorFeedback,
});
return;
}
if (!isSuccess) {
events.emit('feedback', {
type: 'error',
message: 'Error, please try again',
});
return;
}
if (isSuccess && options.successFeedback) {
events.emit('feedback', {
type: 'success',
message: options.successFeedback,
});
return;
}
if (isSuccess && options.undoFeedback) {
events.emit('feedback', {
type: 'undo',
message: options.undoFeedback,
});
}
}
export async function get(path, query = {}, options = {}) {
try {
const res = await fetch(`/api${path}${getQuery(query)}`);
const body = parse(await res.text());
if (res.ok) {
showFeedback(true, options);
return body;
}
showFeedback(false, options, body.statusMessage);
throw new Error(body.statusMessage);
}
catch (error) {
showFeedback(false, options, error.message);
throw error;
}
}
export async function post(path, data, options = {}) {
try {
const isForm = data instanceof FormData;
console.log('POST', isForm, data);
const curatedData = !data || isForm
? data
: JSON.stringify(data);
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
method: 'POST',
body: curatedData,
/*
...postHeaders,
headers: {
...postHeaders.headers,
...(isForm ? { 'Content-Type': 'multipart/form-data' } : {}),
},
*/
});
if (res.status === 204) {
showFeedback(true, options);
return null;
}
const body = parse(await res.text());
if (res.ok) {
showFeedback(true, options);
return body;
}
showFeedback(false, options, body.statusMessage);
throw new Error(body.statusMessage);
}
catch (error) {
showFeedback(false, options, error.message);
throw error;
}
}
export async function postForm(path, form, options = {}) {
try {
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
method: 'POST',
...postHeaders,
headers: {
'Content-Type': 'multipart/form-data',
},
body: form,
});
if (res.status === 204) {
showFeedback(true, options);
return null;
}
const body = parse(await res.text());
if (res.ok) {
showFeedback(true, options);
return body;
}
showFeedback(false, options, body.statusMessage);
throw new Error(body.statusMessage);
}
catch (error) {
showFeedback(false, options, error.message);
throw error;
}
}
export async function patch(path, data, options = {}) {
try {
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
method: 'PATCH',
body: data && JSON.stringify(data),
...postHeaders,
});
if (res.status === 204) {
showFeedback(true, options);
return null;
}
const body = parse(await res.text());
if (res.ok) {
showFeedback(true, options);
return body;
}
showFeedback(false, options, body.statusMessage);
throw new Error(body.statusMessage);
}
catch (error) {
showFeedback(false, options, error.message);
throw error;
}
}
export async function del(path, options = {}) {
try {
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
method: 'DELETE',
body: options.data && JSON.stringify(options.data),
...postHeaders,
});
if (res.status === 204) {
showFeedback(true, options);
return null;
}
const body = parse(await res.text());
if (res.ok) {
showFeedback(true, options);
return body;
}
showFeedback(false, options, body.statusMessage);
throw new Error(body.statusMessage);
}
catch (error) {
showFeedback(false, options, error.message);
throw error;
}
}
//# sourceMappingURL=api.js.map

1
dist/api.js.map vendored Normal file

File diff suppressed because one or more lines are too long

13
dist/app.js vendored Normal file
View File

@ -0,0 +1,13 @@
import config from 'config';
import path from 'path';
import fs from 'fs/promises';
import { initServer } from '#src/web/server.js';
async function init() {
if (config.uploads.flushTempOnStart) {
await fs.rmdir(path.join(config.uploads.path, 'temp'), { recursive: true });
await fs.mkdir(path.join(config.uploads.path, 'temp'));
}
await initServer();
}
init();
//# sourceMappingURL=app.js.map

1
dist/app.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,MAAM,aAAa,CAAC;AAE7B,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEhD,KAAK,UAAU,IAAI;IAClB,IAAI,MAAM,CAAC,OAAO,CAAC,gBAAgB,EAAE,CAAC;QACrC,MAAM,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC5E,MAAM,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IACxD,CAAC;IAED,MAAM,UAAU,EAAE,CAAC;AACpB,CAAC;AAED,IAAI,EAAE,CAAC"}

14
dist/errors.js vendored Normal file
View File

@ -0,0 +1,14 @@
export class HttpError extends Error {
constructor(message, httpCode, friendlyMessage, data) {
super(message);
this.name = 'HttpError';
this.httpCode = httpCode;
if (friendlyMessage) {
this.friendlyMessage = friendlyMessage;
}
if (data) {
this.data = data;
}
}
}
//# sourceMappingURL=errors.js.map

1
dist/errors.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.js"],"names":[],"mappings":"AAAA,MAAM,OAAO,SAAU,SAAQ,KAAK;IACnC,YAAY,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,IAAI;QACnD,KAAK,CAAC,OAAO,CAAC,CAAC;QAEf,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC;QACxB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QAEzB,IAAI,eAAe,EAAE,CAAC;YACrB,IAAI,CAAC,eAAe,GAAG,eAAe,CAAC;QACxC,CAAC;QAED,IAAI,IAAI,EAAE,CAAC;YACV,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QAClB,CAAC;IACF,CAAC;CACD"}

3
dist/events.js vendored Normal file
View File

@ -0,0 +1,3 @@
import mitt from 'mitt';
export default mitt();
//# sourceMappingURL=events.js.map

1
dist/events.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"events.js","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAC;AAExB,eAAe,IAAI,EAAE,CAAC"}

46
dist/files.js vendored Normal file
View File

@ -0,0 +1,46 @@
import config from 'config';
import path from 'path';
import fs from 'fs/promises';
import mime from 'mime';
import crypto from 'crypto';
import { fileTypeFromBuffer } from 'file-type';
import sharp from 'sharp';
import { HttpError } from '#src/errors.js';
function hashFile(fileBuffer) {
return crypto.createHash('sha256')
.update(fileBuffer)
.digest('hex');
}
async function validateMimetype(file, fileBuffer) {
const { mime } = await fileTypeFromBuffer(fileBuffer);
if (mime !== file.mimetype) {
throw new HttpError(`MIME type mismatch: ${file.mimetype} expected, ${mime} found`, 400);
}
}
async function generateThumb(type, fileBuffer, hash, targetPath) {
if (type === 'image') {
await sharp(fileBuffer)
.resize({ height: config.uploads.thumbHeight })
.jpeg({ quality: config.uploads.thumbQuality })
.toFile(path.join(targetPath, `${hash}_t.jpg`));
}
}
export async function uploadFiles(files, options) {
console.log('FILES', files, options);
await files.reduce(async (chain, file) => {
await chain;
const tempFilePath = path.join(config.uploads.path, 'temp', file.tempName);
const fileBuffer = await fs.readFile(tempFilePath);
const hash = hashFile(fileBuffer);
await validateMimetype(file, fileBuffer);
const targetPath = path.join(config.uploads.path, 'full', hash.slice(0, 2), hash.slice(2, 4));
const extension = mime.getExtension(file.mimetype);
await fs.mkdir(targetPath, { recursive: true });
await Promise.all([
generateThumb(file.mimetype.split('/')[0], fileBuffer, hash, targetPath),
fs.rename(path.join(config.uploads.path, 'temp', file.tempName), path.join(targetPath, `${hash}.${extension}`)),
]);
}, Promise.resolve());
return files;
}
//# sourceMappingURL=files.js.map

1
dist/files.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"files.js","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"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"}

13
dist/web/files.js vendored Normal file
View File

@ -0,0 +1,13 @@
import { uploadFiles } from '#src/files.js';
export async function uploadFilesApi(req, res) {
const files = req.files;
const uploads = await uploadFiles(files.map((file) => ({
fileName: file.originalname,
encoding: file.encoding,
mimetype: file.mimetype,
tempName: file.filename,
size: file.size,
})), JSON.parse(req.body.options));
res.send(uploads);
}
//# sourceMappingURL=files.js.map

1
dist/web/files.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"files.js","sourceRoot":"","sources":["../../src/web/files.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,GAAY,EAAE,GAAa;IAC/D,MAAM,KAAK,GAAG,GAAG,CAAC,KAA8B,CAAC;IAEjD,MAAM,OAAO,GAAG,MAAM,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QACrD,QAAQ,EAAE,IAAI,CAAC,YAAY;QAC3B,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,IAAI,EAAE,IAAI,CAAC,IAAI;KAChB,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;IAEnC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AACnB,CAAC"}

5
dist/web/root.js vendored Normal file
View File

@ -0,0 +1,5 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const root = `${__dirname}/../..`;
//# sourceMappingURL=root.js.map

1
dist/web/root.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"root.js","sourceRoot":"","sources":["../../src/web/root.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAC/B,OAAO,EAAE,aAAa,EAAE,MAAM,KAAK,CAAC;AACpC,MAAM,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE1D,MAAM,CAAC,MAAM,IAAI,GAAG,GAAG,SAAS,QAAQ,CAAC"}

55
dist/web/server.js vendored Normal file
View File

@ -0,0 +1,55 @@
import config from 'config';
import express from 'express';
import bodyParser from 'express';
import compression from 'compression';
import { renderPage, createDevMiddleware } from 'vike/server';
import multer from 'multer';
import { root } from '#web/root.js';
import { uploadFilesApi } from '#web/files.js';
const isProduction = process.env.NODE_ENV === 'production';
export async function initServer() {
const app = express();
// const upload = multer({ dest: './uploads' });
const upload = multer({ dest: './uploads/temp' });
app.use(compression());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded());
// Vite integration
if (isProduction) {
// In production, we need to serve our static assets ourselves.
// (In dev, Vite's middleware serves our static assets.)
const sirv = (await import('sirv')).default;
app.use(sirv(`${root}/dist/client`));
}
else {
const { devMiddleware } = await createDevMiddleware({ root });
app.use(devMiddleware);
}
// app.post('/api/files', uploadFilesApi);
app.post('/api/files', upload.array('files'), uploadFilesApi);
// Vike middleware. It should always be our last middleware (because it's a
// catch-all middleware superseding any middleware placed after it).
app.get('*', async (req, res) => {
const pageContextInit = {
urlOriginal: req.originalUrl,
headersOriginal: req.headers
};
const pageContext = await renderPage(pageContextInit);
if (pageContext.errorWhileRendering) {
// Install error tracking here, see https://vike.dev/error-tracking
}
const { httpResponse } = pageContext;
if (res.writeEarlyHints)
res.writeEarlyHints({ link: httpResponse.earlyHints.map((e) => e.earlyHintLink) });
httpResponse.headers.forEach(([name, value]) => res.setHeader(name, value));
res.status(httpResponse.statusCode);
// For HTTP streams use pageContext.httpResponse.pipe() instead, see https://vike.dev/streaming
res.send(httpResponse.body);
});
const host = process.env.HOST || config.web.host || 'localhost';
const port = process.env.PORT || config.web.port || 3000;
app.listen(port, host, () => {
console.log(`Server running at http://${host}:${port}`);
});
}
//# sourceMappingURL=server.js.map

1
dist/web/server.js.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/web/server.ts"],"names":[],"mappings":"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"}

7259
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@ -0,0 +1,49 @@
{
"scripts": {
"dev": "npm run server:dev",
"prod": "npm run build && npm run server:prod",
"build": "vike build",
"server": "node --loader ts-node/esm ./src/app.ts",
"server:dev": "npm run server",
"server:prod": "cross-env NODE_ENV=production npm run server"
},
"dependencies": {
"@types/compression": "^1.7.5",
"@types/express": "^5.0.0",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/compiler-sfc": "^3.5.13",
"@vue/server-renderer": "^3.5.13",
"body-parser": "^2.2.0",
"compression": "^1.7.5",
"config": "^4.1.1",
"cross-env": "^7.0.3",
"express": "^4.21.2",
"file-type": "^21.0.0",
"knex": "^3.1.0",
"mime": "^4.1.0",
"mitt": "^3.0.1",
"multer": "^2.0.2",
"mysql": "^2.18.1",
"node-fetch": "^3.3.2",
"pg": "^8.16.3",
"sharp": "^0.34.4",
"sirv": "^3.0.0",
"sqlite3": "^5.1.7",
"ts-node": "^10.9.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.0",
"vike": "^0.4.223",
"vite": "^6.0.5",
"vue": "^3.5.13",
"vue-tsc": "^2.1.10"
},
"type": "module",
"imports": {
"#src/*": "./src/*",
"#web/*": "./src/web/*",
"#layouts/*": "./layouts/*",
"#pages/*": "./pages/*"
}
}

41
pages/+Layout.vue Normal file
View File

@ -0,0 +1,41 @@
<template>
<div class="container">
<header class="header">
<h1>Pubload</h1>
</header>
<div class="content"><slot /></div>
</div>
</template>
<script lang="ts" setup>
import Link from '../components/Link.vue'
import '../assets/css/style.css'
</script>
<style>
.container {
height: 100%;
display: flex;
flex-direction: column;
color: var(--text);
background: var(--background);
}
.page {
width: 100%;
max-width: 1000px;
}
</style>
<style scoped>
.header {
display: flex;
justify-content: center;
}
.content {
display: flex;
justify-content: center;
}
</style>

28
pages/+config.ts Normal file
View File

@ -0,0 +1,28 @@
import type { Config } from 'vike/types'
// https://vike.dev/config
export default {
// https://vike.dev/clientRouting
clientRouting: true,
// https://vike.dev/meta
meta: {
// Define new setting 'title'
title: {
env: {
server: true,
client: true,
},
},
// Define new setting 'description'
description: {
env: { server: true }
},
Layout: {
env: {
server: true,
client: true,
},
},
},
hydrationCanBeAborted: true
} satisfies Config

25
pages/_error/+Page.vue Normal file
View File

@ -0,0 +1,25 @@
<template>
<div class="center">
<p>{{ abortReason }}</p>
</div>
</template>
<script lang="ts" setup>
import { usePageContext } from '../../renderer/usePageContext'
const pageContext = usePageContext()
let { is404, abortReason } = pageContext.value
if (!abortReason) {
abortReason = is404 ? 'Page not found.' : 'Something went wrong.'
}
</script>
<style>
.center {
height: calc(100vh - 100px);
display: flex;
font-size: 1.3em;
justify-content: center;
align-items: center;
}
</style>

9
pages/index/+Page.vue Normal file
View File

@ -0,0 +1,9 @@
<template>
<div class="page">
<Dropzone />
</div>
</template>
<script lang="ts" setup>
import Dropzone from '#components/upload/drop.vue';
</script>

19
renderer/+config.ts Normal file
View File

@ -0,0 +1,19 @@
import type { Config } from 'vike/types'
// https://vike.dev/config
export default {
// https://vike.dev/clientRouting
clientRouting: true,
// https://vike.dev/meta
meta: {
// Define new setting 'title'
title: {
env: { server: true, client: true }
},
// Define new setting 'description'
description: {
env: { server: true }
}
},
hydrationCanBeAborted: true
} satisfies Config

View File

@ -0,0 +1,9 @@
// https://vike.dev/onPageTransitionEnd
export { onPageTransitionEnd }
import type { OnPageTransitionEndAsync } from 'vike/types'
const onPageTransitionEnd: OnPageTransitionEndAsync = async (): ReturnType<OnPageTransitionEndAsync> => {
console.log('Page transition end')
document.querySelector('body')!.classList.remove('page-is-transitioning')
}

View File

@ -0,0 +1,9 @@
// https://vike.dev/onPageTransitionStart
export { onPageTransitionStart }
import type { OnPageTransitionStartAsync } from 'vike/types'
const onPageTransitionStart: OnPageTransitionStartAsync = async (): ReturnType<OnPageTransitionStartAsync> => {
console.log('Page transition start')
document.querySelector('body')!.classList.add('page-is-transitioning')
}

View File

@ -0,0 +1,21 @@
// https://vike.dev/onRenderClient
export { onRenderClient }
import { createVueApp } from './createVueApp'
import { getPageTitle } from './getPageTitle'
import type { OnRenderClientAsync } from 'vike/types'
let app: ReturnType<typeof createVueApp>
const onRenderClient: OnRenderClientAsync = async (pageContext): ReturnType<OnRenderClientAsync> => {
// This onRenderClient() hook only supports SSR, see https://vike.dev/render-modes for how to modify onRenderClient()
// to support SPA
if (!pageContext.Page) throw new Error('My onRenderClient() hook expects pageContext.Page to be defined')
if (!app) {
app = createVueApp(pageContext)
app.mount('#app')
} else {
app.changePage(pageContext)
}
document.title = getPageTitle(pageContext)
}

55
renderer/+onRenderHtml.ts Normal file
View File

@ -0,0 +1,55 @@
// https://vike.dev/onRenderHtml
export { onRenderHtml }
import { renderToString as renderToString_ } from '@vue/server-renderer'
import type { App } from 'vue'
import { escapeInject, dangerouslySkipEscape } from 'vike/server'
import { createVueApp } from './createVueApp'
import logoUrl from '../assets/img/logo.svg'
import type { OnRenderHtmlAsync } from 'vike/types'
import { getPageTitle } from './getPageTitle'
const onRenderHtml: OnRenderHtmlAsync = async (pageContext): ReturnType<OnRenderHtmlAsync> => {
// This onRenderHtml() hook only supports SSR, see https://vike.dev/render-modes for how to modify
// onRenderHtml() to support SPA
if (!pageContext.Page) throw new Error('My render() hook expects pageContext.Page to be defined')
const app = createVueApp(pageContext)
const appHtml = await renderToString(app)
const title = getPageTitle(pageContext)
const desc = pageContext.data?.description || pageContext.config.description || 'Demo of using Vike'
const documentHtml = escapeInject`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="${logoUrl}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="${desc}" />
<title>${title}</title>
</head>
<body>
<div id="app">${dangerouslySkipEscape(appHtml)}</div>
</body>
</html>`
return {
documentHtml,
pageContext: {
// We can add custom pageContext properties here, see https://vike.dev/pageContext#custom
}
}
}
async function renderToString(app: App) {
let err: unknown
// Workaround: renderToString_() swallows errors in production, see https://github.com/vuejs/core/issues/7876
app.config.errorHandler = (err_) => {
err = err_
}
const appHtml = await renderToString_(app)
if (err) throw err
return appHtml
}

30
renderer/createVueApp.ts Normal file
View File

@ -0,0 +1,30 @@
import { createSSRApp, h, shallowRef } from 'vue'
import { setPageContext } from './usePageContext'
import { setData } from './useData'
import type { PageContext } from 'vike/types'
import { objectAssign } from './utils'
// import Layout from './Layout.vue'
export function createVueApp(pageContext: PageContext) {
const pageContextRef = shallowRef(pageContext)
const dataRef = shallowRef(pageContext.data)
const pageRef = shallowRef(pageContext.Page)
// const RootComponent = () => h(Layout, null, () => h(pageRef.value))
const RootComponent = () => h(pageContext.config.Layout, null, () => h(pageRef.value))
const app = createSSRApp(RootComponent)
setPageContext(app, pageContextRef)
setData(app, dataRef)
// app.changePage() is called upon navigation, see +onRenderClient.ts
objectAssign(app, {
changePage: (pageContext: PageContext) => {
pageContextRef.value = pageContext
dataRef.value = pageContext.data
pageRef.value = pageContext.Page
}
})
return app
}

14
renderer/getPageTitle.ts Normal file
View File

@ -0,0 +1,14 @@
export { getPageTitle }
import type { PageContext } from 'vike/types'
function getPageTitle(pageContext: PageContext): string {
const title =
// Title defined dynamically by data()
pageContext.data?.title ||
// Title defined statically by /pages/some-page/+title.js (or by `export default { title }` in /pages/some-page/+config.js)
// The setting 'pageContext.config.title' is a custom setting we defined at ./+config.ts
pageContext.config.title ||
'Vike Demo'
return title
}

29
renderer/types.ts Normal file
View File

@ -0,0 +1,29 @@
export type { Component }
import type { ComponentPublicInstance } from 'vue'
type Component = ComponentPublicInstance // https://stackoverflow.com/questions/63985658/how-to-type-vue-instance-out-of-definecomponent-in-vue-3/63986086#63986086
type Page = Component
// https://vike.dev/pageContext#typescript
declare global {
namespace Vike {
interface PageContext {
Page: Page
data?: {
/** Value for <title> defined dynamically by by /pages/some-page/+data.js */
title?: string
/** Value for <meta name="description"> defined dynamically */
description?: string
}
config: {
/** Value for <title> defined statically by /pages/some-page/+title.js (or by `export default { title }` in /pages/some-page/+config.js) */
title?: string
/** Value for <meta name="description"> defined statically */
description?: string
}
/** https://vike.dev/render */
abortReason?: string
}
}
}

19
renderer/useData.ts Normal file
View File

@ -0,0 +1,19 @@
// https://vike.dev/useData
export { useData }
export { setData }
import { inject } from 'vue'
import type { App, InjectionKey, Ref } from 'vue'
const key: InjectionKey<Ref<unknown>> = Symbol()
/** https://vike.dev/useData */
function useData<Data>(): Ref<Data> {
const data = inject(key)
if (!data) throw new Error('setData() not called')
return data as Ref<Data>
}
function setData(app: App, data: Ref<unknown>): void {
app.provide(key, data)
}

View File

@ -0,0 +1,20 @@
// https://vike.dev/usePageContext
export { usePageContext }
export { setPageContext }
import { inject } from 'vue'
import type { App, InjectionKey, Ref } from 'vue'
import type { PageContext } from 'vike/types'
const key: InjectionKey<Ref<PageContext>> = Symbol()
/** https://vike.dev/usePageContext */
function usePageContext(): Ref<PageContext> {
const pageContext = inject(key)
if (!pageContext) throw new Error('setPageContext() not called in parent')
return pageContext
}
function setPageContext(app: App, pageContext: Ref<PageContext>): void {
app.provide(key, pageContext)
}

7
renderer/utils.ts Normal file
View File

@ -0,0 +1,7 @@
// Same as Object.assign() but with type inference
export function objectAssign<Obj extends object, ObjAddendum>(
obj: Obj,
objAddendum: ObjAddendum
): asserts obj is Obj & ObjAddendum {
Object.assign(obj, objAddendum)
}

219
src/api.ts Normal file
View File

@ -0,0 +1,219 @@
import { parse } from '@brillout/json-serializer/parse'; /* eslint-disable-line import/extensions */
import events from '#src/events';
import { ResBody } from '#src/web/server';
const postHeaders: Partial<RequestInit> = {
mode: 'cors',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
};
type FeedbackOptions = {
errorFeedback?: string,
successFeedback?: string,
undoFeedback?: string,
appendErrorMessage?: boolean,
};
type RequestOptions = FeedbackOptions & {
query?: {},
data?: {},
};
function getQuery(data: Object) {
if (!data) {
return '';
}
const curatedQuery = Object.fromEntries(Object.entries(data).map(([key, value]) => (value === undefined ? null : [key, value])).filter(Boolean));
return `?${new URLSearchParams(curatedQuery).toString()}`; // recode so commas aren't encoded
}
function showFeedback(isSuccess: Boolean, options:FeedbackOptions = {}, errorMessage?: string) {
if (!isSuccess && (typeof options.errorFeedback === 'string' || options.appendErrorMessage)) {
events.emit('feedback', {
type: 'error',
message: options.appendErrorMessage && errorMessage
? `${options.errorFeedback ? `${options.errorFeedback}: ` : ''}${errorMessage}`
: options.errorFeedback,
});
return;
}
if (!isSuccess) {
events.emit('feedback', {
type: 'error',
message: 'Error, please try again',
});
return;
}
if (isSuccess && options.successFeedback) {
events.emit('feedback', {
type: 'success',
message: options.successFeedback,
});
return;
}
if (isSuccess && options.undoFeedback) {
events.emit('feedback', {
type: 'undo',
message: options.undoFeedback,
});
}
}
export async function get(path, query = {}, options: RequestOptions = {}) {
try {
const res = await fetch(`/api${path}${getQuery(query)}`);
const body = parse(await res.text()) as ResBody;
if (res.ok) {
showFeedback(true, options);
return body;
}
showFeedback(false, options, body.statusMessage);
throw new Error(body.statusMessage);
} catch (error) {
showFeedback(false, options, error.message);
throw error;
}
}
export async function post(path, data, options: RequestOptions = {}) {
try {
const isForm = data instanceof FormData;
console.log('POST', isForm, data);
const curatedData = !data || isForm
? data
: JSON.stringify(data);
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
method: 'POST',
body: curatedData,
/*
...postHeaders,
headers: {
...postHeaders.headers,
...(isForm ? { 'Content-Type': 'multipart/form-data' } : {}),
},
*/
});
if (res.status === 204) {
showFeedback(true, options);
return null;
}
const body: ResBody = parse(await res.text()) as ResBody;
if (res.ok) {
showFeedback(true, options);
return body;
}
showFeedback(false, options, body.statusMessage);
throw new Error(body.statusMessage);
} catch (error) {
showFeedback(false, options, error.message);
throw error;
}
}
export async function postForm(path, form, options: RequestOptions = {}) {
try {
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
method: 'POST',
...postHeaders,
headers: {
'Content-Type': 'multipart/form-data',
},
body: form,
});
if (res.status === 204) {
showFeedback(true, options);
return null;
}
const body: ResBody = parse(await res.text()) as ResBody;
if (res.ok) {
showFeedback(true, options);
return body;
}
showFeedback(false, options, body.statusMessage);
throw new Error(body.statusMessage);
} catch (error) {
showFeedback(false, options, error.message);
throw error;
}
}
export async function patch(path, data, options: RequestOptions = {}) {
try {
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
method: 'PATCH',
body: data && JSON.stringify(data),
...postHeaders,
});
if (res.status === 204) {
showFeedback(true, options);
return null;
}
const body = parse(await res.text()) as ResBody;
if (res.ok) {
showFeedback(true, options);
return body;
}
showFeedback(false, options, body.statusMessage);
throw new Error(body.statusMessage);
} catch (error) {
showFeedback(false, options, error.message);
throw error;
}
}
export async function del(path, options: RequestOptions = {}) {
try {
const res = await fetch(`/api${path}${getQuery(options.query)}`, {
method: 'DELETE',
body: options.data && JSON.stringify(options.data),
...postHeaders,
});
if (res.status === 204) {
showFeedback(true, options);
return null;
}
const body = parse(await res.text()) as ResBody;
if (res.ok) {
showFeedback(true, options);
return body;
}
showFeedback(false, options, body.statusMessage);
throw new Error(body.statusMessage);
} catch (error) {
showFeedback(false, options, error.message);
throw error;
}
}

16
src/app.ts Normal file
View File

@ -0,0 +1,16 @@
import config from 'config';
import path from 'path';
import fs from 'fs/promises';
import { initServer } from '#src/web/server.js';
async function init() {
if (config.uploads.flushTempOnStart) {
await fs.rmdir(path.join(config.uploads.path, 'temp'), { recursive: true });
await fs.mkdir(path.join(config.uploads.path, 'temp'));
}
await initServer();
}
init();

16
src/errors.js Executable file
View File

@ -0,0 +1,16 @@
export class HttpError extends Error {
constructor(message, httpCode, friendlyMessage, data) {
super(message);
this.name = 'HttpError';
this.httpCode = httpCode;
if (friendlyMessage) {
this.friendlyMessage = friendlyMessage;
}
if (data) {
this.data = data;
}
}
}

3
src/events.ts Normal file
View File

@ -0,0 +1,3 @@
import mitt from 'mitt';
export default mitt();

71
src/files.ts Normal file
View File

@ -0,0 +1,71 @@
import config from 'config';
import path from 'path';
import fs from 'fs/promises';
import mime from 'mime';
import crypto from 'crypto';
import { fileTypeFromBuffer } from 'file-type';
import sharp from 'sharp';
import { HttpError } from '#src/errors.js';
type Upload = {
fileName: string,
encoding: string,
mimetype: string,
tempName: string,
size: number,
};
type UploadOptions = {
addToAlbum: boolean,
};
function hashFile(fileBuffer) {
return crypto.createHash('sha256')
.update(fileBuffer)
.digest('hex');
}
async function validateMimetype(file, fileBuffer) {
const { mime } = await fileTypeFromBuffer(fileBuffer);
if (mime !== file.mimetype) {
throw new HttpError(`MIME type mismatch: ${file.mimetype} expected, ${mime} found`, 400);
}
}
async function generateThumb(type, fileBuffer, hash, targetPath) {
if (type === 'image') {
await sharp(fileBuffer)
.resize({ height: config.uploads.thumbHeight })
.jpeg({ quality: config.uploads.thumbQuality })
.toFile(path.join(targetPath, `${hash}_t.jpg`));
}
}
export async function uploadFiles(files: Upload[], options: UploadOptions) {
console.log('FILES', files, options);
await files.reduce(async (chain, file) => {
await chain;
const tempFilePath = path.join(config.uploads.path, 'temp', file.tempName);
const fileBuffer = await fs.readFile(tempFilePath);
const hash = hashFile(fileBuffer);
await validateMimetype(file, fileBuffer);
const targetPath = path.join(config.uploads.path, 'full', hash.slice(0, 2), hash.slice(2, 4));
const extension = mime.getExtension(file.mimetype);
await fs.mkdir(targetPath, { recursive: true });
await Promise.all([
generateThumb(file.mimetype.split('/')[0], fileBuffer, hash, targetPath),
fs.rename(path.join(config.uploads.path, 'temp', file.tempName), path.join(targetPath, `${hash}.${extension}`)),
]);
}, Promise.resolve());
return files;
}

17
src/web/files.ts Normal file
View File

@ -0,0 +1,17 @@
import { Request, Response } from 'express';
import { uploadFiles } from '#src/files.js';
export async function uploadFilesApi(req: Request, res: Response) {
const files = req.files as Express.Multer.File[];
const uploads = await uploadFiles(files.map((file) => ({
fileName: file.originalname,
encoding: file.encoding,
mimetype: file.mimetype,
tempName: file.filename,
size: file.size,
})), JSON.parse(req.body.options));
res.send(uploads);
}

5
src/web/root.ts Normal file
View File

@ -0,0 +1,5 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const root = `${__dirname}/../..`;

67
src/web/server.ts Normal file
View File

@ -0,0 +1,67 @@
import config from 'config';
import express from 'express';
import bodyParser from 'express';
import compression from 'compression';
import { renderPage, createDevMiddleware } from 'vike/server';
import multer from 'multer';
import { root } from '#web/root.js';
import { uploadFilesApi } from '#web/files.js';
const isProduction = process.env.NODE_ENV === 'production'
export type ResBody = {
body?: {},
statusMessage: string,
statusCode: number,
};
export async function initServer() {
const app = express()
// const upload = multer({ dest: './uploads' });
const upload = multer({ dest: './uploads/temp' });
app.use(compression())
app.use(bodyParser.json())
app.use(bodyParser.urlencoded())
// Vite integration
if (isProduction) {
// In production, we need to serve our static assets ourselves.
// (In dev, Vite's middleware serves our static assets.)
const sirv = (await import('sirv')).default
app.use(sirv(`${root}/dist/client`))
} else {
const { devMiddleware } = await createDevMiddleware({ root })
app.use(devMiddleware)
}
// app.post('/api/files', uploadFilesApi);
app.post('/api/files', upload.array('files'), uploadFilesApi);
// Vike middleware. It should always be our last middleware (because it's a
// catch-all middleware superseding any middleware placed after it).
app.get('*', async (req, res) => {
const pageContextInit = {
urlOriginal: req.originalUrl,
headersOriginal: req.headers
}
const pageContext = await renderPage(pageContextInit)
if (pageContext.errorWhileRendering) {
// Install error tracking here, see https://vike.dev/error-tracking
}
const { httpResponse } = pageContext
if (res.writeEarlyHints) res.writeEarlyHints({ link: httpResponse.earlyHints.map((e) => e.earlyHintLink) })
httpResponse.headers.forEach(([name, value]) => res.setHeader(name, value))
res.status(httpResponse.statusCode)
// For HTTP streams use pageContext.httpResponse.pipe() instead, see https://vike.dev/streaming
res.send(httpResponse.body)
})
const host = process.env.HOST || config.web.host || 'localhost';
const port = process.env.PORT || config.web.port || 3000;
app.listen(port, host, () => {
console.log(`Server running at http://${host}:${port}`)
});
}

9
src/web/tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
// Make IDEs complain about missing file extension .js in import paths.
// Alternatively, we could always set "module" to "Node16" and add the file extension .js to import paths everywhere.
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16"
}
}

45
tsconfig.json Normal file
View File

@ -0,0 +1,45 @@
{
"compilerOptions": {
"outDir": "dist",
"strict": false,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"module": "ESNext",
"moduleResolution": "node",
"target": "ES2022",
"useUnknownInCatchVariables": false,
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"types": [
"vite/client"
],
"jsx": "preserve",
"jsxImportSource": "vue",
"paths": {
"#src/*": ["./src/*"],
"#web/*": ["./src/web/*"],
"#layouts/*": ["./layouts/*"],
"#pages/*": ["./pages/*"],
"#assets/*": ["./assets/*"],
"#/*": ["./*"]
}
},
"include": [
"types/*",
"src/**/*"
],
"exclude": [
"dist",
"migrations",
"seeds",
"node_modules"
]
}

21
vite.config.ts Normal file
View File

@ -0,0 +1,21 @@
import path from 'path';
import vue from '@vitejs/plugin-vue'
import vike from 'vike/plugin'
import { UserConfig } from 'vite'
const config: UserConfig = {
plugins: [vue(), vike()],
resolve: {
alias: {
'#src': path.join(__dirname, 'src'),
'#web': path.join(__dirname, 'src', 'web'),
'#pages': path.join(__dirname, 'pages'),
'#components': path.join(__dirname, 'components'),
'#renderer': path.join(__dirname, 'renderer'),
'#assets': path.join(__dirname, 'assets'),
// '#': __dirname,
},
},
}
export default config

4
vue.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.vue' {
const Component: any
export default Component
}