feat: add download page
This commit is contained in:
parent
efee85cf31
commit
ee21b17935
@ -11,14 +11,18 @@
|
||||
--color-yellow-500: oklch(79.5% 0.184 86.047);
|
||||
--color-green-500: oklch(72.3% 0.219 149.579);
|
||||
--color-blue-500: oklch(62.3% 0.214 259.815);
|
||||
--color-slate-950: oklch(12.9% 0.042 264.695);
|
||||
--color-gray-300: oklch(87.2% 0.01 258.338);
|
||||
--color-gray-400: oklch(70.7% 0.022 261.325);
|
||||
--color-gray-500: oklch(55.1% 0.027 264.364);
|
||||
--color-gray-800: oklch(27.8% 0.033 256.848);
|
||||
--color-gray-950: oklch(13% 0.028 261.692);
|
||||
--color-white: #fff;
|
||||
--spacing: 0.25rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
--text-sm: 0.875rem;
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
--text-xl: 1.25rem;
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
--text-2xl: 1.5rem;
|
||||
--text-2xl--line-height: calc(2 / 1.5);
|
||||
--text-3xl: 1.875rem;
|
||||
@ -31,7 +35,6 @@
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--default-transition-duration: 150ms;
|
||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--default-font-family: var(--font-sans);
|
||||
@ -228,6 +231,9 @@
|
||||
.pointer-events-auto {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.pointer-events-none {
|
||||
pointer-events: none;
|
||||
}
|
||||
.collapse {
|
||||
visibility: collapse;
|
||||
}
|
||||
@ -240,27 +246,15 @@
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
.inset-0 {
|
||||
inset: calc(var(--spacing) * 0);
|
||||
}
|
||||
.top-0 {
|
||||
top: calc(var(--spacing) * 0);
|
||||
}
|
||||
.right-0 {
|
||||
right: calc(var(--spacing) * 0);
|
||||
}
|
||||
.bottom-0 {
|
||||
bottom: calc(var(--spacing) * 0);
|
||||
}
|
||||
.left-0 {
|
||||
left: calc(var(--spacing) * 0);
|
||||
}
|
||||
.left-1 {
|
||||
left: calc(var(--spacing) * 1);
|
||||
}
|
||||
.left-1\/2 {
|
||||
left: calc(1/2 * 100%);
|
||||
}
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
@ -294,6 +288,9 @@
|
||||
.mt-1 {
|
||||
margin-top: calc(var(--spacing) * 1);
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mr-3 {
|
||||
margin-right: calc(var(--spacing) * 3);
|
||||
}
|
||||
@ -318,6 +315,10 @@
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.size-4 {
|
||||
width: calc(var(--spacing) * 4);
|
||||
height: calc(var(--spacing) * 4);
|
||||
}
|
||||
.size-9 {
|
||||
width: calc(var(--spacing) * 9);
|
||||
height: calc(var(--spacing) * 9);
|
||||
@ -343,15 +344,27 @@
|
||||
.h-screen {
|
||||
height: 100vh;
|
||||
}
|
||||
.w-2 {
|
||||
width: calc(var(--spacing) * 2);
|
||||
}
|
||||
.w-3 {
|
||||
width: calc(var(--spacing) * 3);
|
||||
}
|
||||
.w-4 {
|
||||
width: calc(var(--spacing) * 4);
|
||||
}
|
||||
.w-7 {
|
||||
width: calc(var(--spacing) * 7);
|
||||
.w-9 {
|
||||
width: calc(var(--spacing) * 9);
|
||||
}
|
||||
.w-40 {
|
||||
width: calc(var(--spacing) * 40);
|
||||
}
|
||||
.w-\[39ch\] {
|
||||
width: 39ch;
|
||||
}
|
||||
.w-fit {
|
||||
width: fit-content;
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
@ -379,28 +392,15 @@
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.caption-bottom {
|
||||
caption-side: bottom;
|
||||
}
|
||||
.border-collapse {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.origin-left {
|
||||
transform-origin: left;
|
||||
}
|
||||
.-translate-x-1 {
|
||||
--tw-translate-x: calc(var(--spacing) * -1);
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
.-translate-x-1\/2 {
|
||||
--tw-translate-x: calc(calc(1/2 * 100%) * -1);
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
.-translate-y-4 {
|
||||
--tw-translate-y: calc(var(--spacing) * -4);
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
.translate-y-4 {
|
||||
--tw-translate-y: calc(var(--spacing) * 4);
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
.scale-3d {
|
||||
scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);
|
||||
}
|
||||
@ -437,6 +437,9 @@
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
@ -452,17 +455,20 @@
|
||||
.gap-3 {
|
||||
gap: calc(var(--spacing) * 3);
|
||||
}
|
||||
.gap-6 {
|
||||
gap: calc(var(--spacing) * 6);
|
||||
}
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.overflow-auto {
|
||||
overflow: auto;
|
||||
}
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
.rounded-\[4px\] {
|
||||
border-radius: 4px;
|
||||
}
|
||||
.rounded-full {
|
||||
border-radius: calc(infinity * 1px);
|
||||
}
|
||||
@ -483,6 +489,14 @@
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 2px;
|
||||
}
|
||||
.border-t {
|
||||
border-top-style: var(--tw-border-style);
|
||||
border-top-width: 1px;
|
||||
}
|
||||
.border-b {
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.border-dashed {
|
||||
--tw-border-style: dashed;
|
||||
border-style: dashed;
|
||||
@ -490,27 +504,42 @@
|
||||
.border-gray-400 {
|
||||
border-color: var(--color-gray-400);
|
||||
}
|
||||
.border-input {
|
||||
border-color: var(--input);
|
||||
}
|
||||
.border-primary {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.border-transparent {
|
||||
border-color: transparent;
|
||||
}
|
||||
.bg-background {
|
||||
background-color: var(--background);
|
||||
}
|
||||
.bg-blue-500 {
|
||||
background-color: var(--color-blue-500);
|
||||
}
|
||||
.bg-destructive {
|
||||
background-color: var(--destructive);
|
||||
}
|
||||
.bg-gray-300 {
|
||||
background-color: var(--color-gray-300);
|
||||
}
|
||||
.bg-gray-500 {
|
||||
background-color: var(--color-gray-500);
|
||||
.bg-gray-800 {
|
||||
background-color: var(--color-gray-800);
|
||||
}
|
||||
.bg-gray-950 {
|
||||
background-color: var(--color-gray-950);
|
||||
}
|
||||
.bg-green-500 {
|
||||
background-color: var(--color-green-500);
|
||||
}
|
||||
.bg-muted {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
.bg-muted\/50 {
|
||||
background-color: var(--muted);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--muted) 50%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-popover {
|
||||
background-color: var(--popover);
|
||||
}
|
||||
@ -526,8 +555,8 @@
|
||||
.bg-selection {
|
||||
background-color: var(--selection);
|
||||
}
|
||||
.bg-yellow-500 {
|
||||
background-color: var(--color-yellow-500);
|
||||
.bg-white {
|
||||
background-color: var(--color-white);
|
||||
}
|
||||
.p-0 {
|
||||
padding: calc(var(--spacing) * 0);
|
||||
@ -541,12 +570,21 @@
|
||||
.p-4 {
|
||||
padding: calc(var(--spacing) * 4);
|
||||
}
|
||||
.p-5 {
|
||||
padding: calc(var(--spacing) * 5);
|
||||
}
|
||||
.p-7 {
|
||||
padding: calc(var(--spacing) * 7);
|
||||
}
|
||||
.p-8 {
|
||||
padding: calc(var(--spacing) * 8);
|
||||
}
|
||||
.p-10 {
|
||||
padding: calc(var(--spacing) * 10);
|
||||
}
|
||||
.px-2 {
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
}
|
||||
.px-3 {
|
||||
padding-inline: calc(var(--spacing) * 3);
|
||||
}
|
||||
@ -556,6 +594,12 @@
|
||||
.px-6 {
|
||||
padding-inline: calc(var(--spacing) * 6);
|
||||
}
|
||||
.py-0 {
|
||||
padding-block: calc(var(--spacing) * 0);
|
||||
}
|
||||
.py-0\.5 {
|
||||
padding-block: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
.py-2 {
|
||||
padding-block: calc(var(--spacing) * 2);
|
||||
}
|
||||
@ -568,12 +612,15 @@
|
||||
.pl-2 {
|
||||
padding-left: calc(var(--spacing) * 2);
|
||||
}
|
||||
.pl-4 {
|
||||
padding-left: calc(var(--spacing) * 4);
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
.align-middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.text-2xl {
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
||||
@ -590,6 +637,10 @@
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||
}
|
||||
.text-xs {
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--tw-leading, var(--text-xs--line-height));
|
||||
}
|
||||
.leading-none {
|
||||
--tw-leading: 1;
|
||||
line-height: 1;
|
||||
@ -611,9 +662,15 @@
|
||||
.text-destructive {
|
||||
color: var(--destructive);
|
||||
}
|
||||
.text-foreground {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.text-green-500 {
|
||||
color: var(--color-green-500);
|
||||
}
|
||||
.text-muted-foreground {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.text-popover-foreground {
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
@ -629,6 +686,9 @@
|
||||
.text-secondary-foreground {
|
||||
color: var(--secondary-foreground);
|
||||
}
|
||||
.text-white {
|
||||
color: var(--color-white);
|
||||
}
|
||||
.text-yellow-500 {
|
||||
color: var(--color-yellow-500);
|
||||
}
|
||||
@ -670,11 +730,26 @@
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.transition-\[color\,box-shadow\] {
|
||||
transition-property: color,box-shadow;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.transition-colors {
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.transition-shadow {
|
||||
transition-property: box-shadow;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.transition-transform {
|
||||
transition-property: transform, translate, scale, rotate;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
@ -688,14 +763,15 @@
|
||||
--tw-ease: linear;
|
||||
transition-timing-function: linear;
|
||||
}
|
||||
.ease-out {
|
||||
--tw-ease: var(--ease-out);
|
||||
transition-timing-function: var(--ease-out);
|
||||
}
|
||||
.outline-none {
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
}
|
||||
.peer-checked\:opacity-100 {
|
||||
&:is(:where(.peer):checked ~ *) {
|
||||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
.before\:absolute {
|
||||
&::before {
|
||||
content: var(--tw-content);
|
||||
@ -762,6 +838,11 @@
|
||||
background-color: var(--primary);
|
||||
}
|
||||
}
|
||||
.checked\:text-primary-foreground {
|
||||
&:checked {
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
}
|
||||
.checked\:before\:visible {
|
||||
&:checked {
|
||||
&::before {
|
||||
@ -794,6 +875,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-muted\/50 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--muted);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--muted) 50%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-primary\/90 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@ -894,6 +985,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.focus-visible\:outline-none {
|
||||
&:focus-visible {
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
}
|
||||
}
|
||||
.disabled\:pointer-events-none {
|
||||
&:disabled {
|
||||
pointer-events: none;
|
||||
@ -1009,6 +1106,11 @@
|
||||
right: calc(var(--spacing) * 0);
|
||||
}
|
||||
}
|
||||
.data-\[tui-table-state-selected\]\:bg-muted {
|
||||
&[data-tui-table-state-selected] {
|
||||
background-color: var(--muted);
|
||||
}
|
||||
}
|
||||
.data-\[variant\=default\]\:bg-gray-500 {
|
||||
&[data-variant="default"] {
|
||||
background-color: var(--color-gray-500);
|
||||
@ -1034,6 +1136,16 @@
|
||||
background-color: var(--color-yellow-500);
|
||||
}
|
||||
}
|
||||
.sm\:flex {
|
||||
@media (width >= 40rem) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.md\:w-3\/4 {
|
||||
@media (width >= 48rem) {
|
||||
width: calc(3/4 * 100%);
|
||||
}
|
||||
}
|
||||
.md\:max-w-\[420px\] {
|
||||
@media (width >= 48rem) {
|
||||
max-width: 420px;
|
||||
@ -1060,6 +1172,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.dark\:text-white {
|
||||
&:where(.dark, .dark *) {
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
.dark\:hover\:bg-accent\/50 {
|
||||
&:where(.dark, .dark *) {
|
||||
&:hover {
|
||||
@ -1120,6 +1237,102 @@
|
||||
height: calc(var(--spacing) * 4);
|
||||
}
|
||||
}
|
||||
.\[\&_tr\]\:border-b {
|
||||
& tr {
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
}
|
||||
.\[\&_tr\:last-child\]\:border-0 {
|
||||
& tr:last-child {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 0px;
|
||||
}
|
||||
}
|
||||
.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0 {
|
||||
&:has([role=checkbox]) {
|
||||
padding-right: calc(var(--spacing) * 0);
|
||||
}
|
||||
}
|
||||
.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\] {
|
||||
&>[role=checkbox] {
|
||||
--tw-translate-y: 2px;
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
}
|
||||
.\[\&\>svg\]\:pointer-events-none {
|
||||
&>svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
.\[\&\>svg\]\:size-3 {
|
||||
&>svg {
|
||||
width: calc(var(--spacing) * 3);
|
||||
height: calc(var(--spacing) * 3);
|
||||
}
|
||||
}
|
||||
.\[\&\>tr\]\:last\:border-b-0 {
|
||||
&>tr {
|
||||
&:last-child {
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.\[a\&\]\:hover\:bg-accent {
|
||||
a& {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.\[a\&\]\:hover\:bg-destructive\/90 {
|
||||
a& {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--destructive);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--destructive) 90%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.\[a\&\]\:hover\:bg-primary\/90 {
|
||||
a& {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--primary);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--primary) 90%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.\[a\&\]\:hover\:bg-secondary\/90 {
|
||||
a& {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--secondary);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--secondary) 90%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.\[a\&\]\:hover\:text-accent-foreground {
|
||||
a& {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
@ -1207,21 +1420,6 @@
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
@property --tw-translate-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
@property --tw-translate-y {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
@property --tw-translate-z {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
@property --tw-scale-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@ -1353,12 +1551,24 @@
|
||||
initial-value: "";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-translate-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
@property --tw-translate-y {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
@property --tw-translate-z {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
@layer properties {
|
||||
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
||||
*, ::before, ::after, ::backdrop {
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-translate-z: 0;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-scale-z: 1;
|
||||
@ -1388,6 +1598,9 @@
|
||||
--tw-duration: initial;
|
||||
--tw-ease: initial;
|
||||
--tw-content: "";
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-translate-z: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
58
components/badge/badge.templ
Normal file
58
components/badge/badge.templ
Normal file
@ -0,0 +1,58 @@
|
||||
// templui component badge - version: v0.94.0 installed by templui v0.94.0
|
||||
package badge
|
||||
|
||||
import "git.hafen.run/lukas/timeshare/utils"
|
||||
|
||||
type Variant string
|
||||
|
||||
const (
|
||||
VariantDefault Variant = "default"
|
||||
VariantSecondary Variant = "secondary"
|
||||
VariantDestructive Variant = "destructive"
|
||||
VariantOutline Variant = "outline"
|
||||
)
|
||||
|
||||
type Props struct {
|
||||
ID string
|
||||
Class string
|
||||
Attributes templ.Attributes
|
||||
Variant Variant
|
||||
}
|
||||
|
||||
templ Badge(props ...Props) {
|
||||
{{ var p Props }}
|
||||
if len(props) > 0 {
|
||||
{{ p = props[0] }}
|
||||
}
|
||||
<span
|
||||
if p.ID != "" {
|
||||
id={ p.ID }
|
||||
}
|
||||
class={
|
||||
utils.TwMerge(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"transition-[color,box-shadow] overflow-hidden",
|
||||
p.variantClasses(),
|
||||
p.Class,
|
||||
),
|
||||
}
|
||||
{ p.Attributes... }
|
||||
>
|
||||
{ children... }
|
||||
</span>
|
||||
}
|
||||
|
||||
func (p Props) variantClasses() string {
|
||||
switch p.Variant {
|
||||
case VariantDestructive:
|
||||
return "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60"
|
||||
case VariantOutline:
|
||||
return "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground"
|
||||
case VariantSecondary:
|
||||
return "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90"
|
||||
default:
|
||||
return "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90"
|
||||
}
|
||||
}
|
70
components/checkbox/checkbox.templ
Normal file
70
components/checkbox/checkbox.templ
Normal file
@ -0,0 +1,70 @@
|
||||
// templui component checkbox - version: v0.94.0 installed by templui v0.94.0
|
||||
package checkbox
|
||||
|
||||
import (
|
||||
"git.hafen.run/lukas/timeshare/components/icon"
|
||||
"git.hafen.run/lukas/timeshare/utils"
|
||||
)
|
||||
|
||||
type Props struct {
|
||||
ID string
|
||||
Class string
|
||||
Attributes templ.Attributes
|
||||
Name string
|
||||
Value string
|
||||
Disabled bool
|
||||
Required bool
|
||||
Checked bool
|
||||
Form string
|
||||
Icon templ.Component
|
||||
}
|
||||
|
||||
templ Checkbox(props ...Props) {
|
||||
{{ var p Props }}
|
||||
if len(props) > 0 {
|
||||
{{ p = props[0] }}
|
||||
}
|
||||
<div class="relative inline-flex items-center">
|
||||
<input
|
||||
checked?={ p.Checked }
|
||||
disabled?={ p.Disabled }
|
||||
required?={ p.Required }
|
||||
if p.ID != "" {
|
||||
id={ p.ID }
|
||||
}
|
||||
if p.Name != "" {
|
||||
name={ p.Name }
|
||||
}
|
||||
if p.Value != "" {
|
||||
value={ p.Value }
|
||||
} else {
|
||||
value="on"
|
||||
}
|
||||
if p.Form != "" {
|
||||
form={ p.Form }
|
||||
}
|
||||
type="checkbox"
|
||||
class={
|
||||
utils.TwMerge(
|
||||
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs",
|
||||
"focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"checked:bg-primary checked:text-primary-foreground checked:border-primary",
|
||||
"appearance-none cursor-pointer transition-shadow",
|
||||
"relative",
|
||||
p.Class,
|
||||
),
|
||||
}
|
||||
{ p.Attributes... }
|
||||
/>
|
||||
<div
|
||||
class="absolute left-0 top-0 h-4 w-4 pointer-events-none flex items-center justify-center text-primary-foreground opacity-0 peer-checked:opacity-100"
|
||||
>
|
||||
if p.Icon != nil {
|
||||
@p.Icon
|
||||
} else {
|
||||
@icon.Check(icon.Props{Size: 14})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
204
components/table/table.templ
Normal file
204
components/table/table.templ
Normal file
@ -0,0 +1,204 @@
|
||||
// templui component table - version: v0.94.0 installed by templui v0.94.0
|
||||
package table
|
||||
|
||||
import "git.hafen.run/lukas/timeshare/utils"
|
||||
|
||||
type Props struct {
|
||||
ID string
|
||||
Class string
|
||||
Attributes templ.Attributes
|
||||
}
|
||||
|
||||
type HeaderProps struct {
|
||||
ID string
|
||||
Class string
|
||||
Attributes templ.Attributes
|
||||
}
|
||||
|
||||
type BodyProps struct {
|
||||
ID string
|
||||
Class string
|
||||
Attributes templ.Attributes
|
||||
}
|
||||
|
||||
type FooterProps struct {
|
||||
ID string
|
||||
Class string
|
||||
Attributes templ.Attributes
|
||||
}
|
||||
|
||||
type RowProps struct {
|
||||
ID string
|
||||
Class string
|
||||
Attributes templ.Attributes
|
||||
Selected bool
|
||||
}
|
||||
|
||||
type HeadProps struct {
|
||||
ID string
|
||||
Class string
|
||||
Attributes templ.Attributes
|
||||
}
|
||||
|
||||
type CellProps struct {
|
||||
ID string
|
||||
Class string
|
||||
Attributes templ.Attributes
|
||||
}
|
||||
|
||||
type CaptionProps struct {
|
||||
ID string
|
||||
Class string
|
||||
Attributes templ.Attributes
|
||||
}
|
||||
|
||||
templ Table(props ...Props) {
|
||||
{{ var p Props }}
|
||||
if len(props) > 0 {
|
||||
{{ p = props[0] }}
|
||||
}
|
||||
<div class="relative w-full overflow-auto">
|
||||
<table
|
||||
if p.ID != "" {
|
||||
id={ p.ID }
|
||||
}
|
||||
class={ utils.TwMerge("w-full caption-bottom text-sm", p.Class) }
|
||||
{ p.Attributes... }
|
||||
>
|
||||
{ children... }
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Header(props ...HeaderProps) {
|
||||
{{ var p HeaderProps }}
|
||||
if len(props) > 0 {
|
||||
{{ p = props[0] }}
|
||||
}
|
||||
<thead
|
||||
if p.ID != "" {
|
||||
id={ p.ID }
|
||||
}
|
||||
class={ utils.TwMerge("[&_tr]:border-b", p.Class) }
|
||||
{ p.Attributes... }
|
||||
>
|
||||
{ children... }
|
||||
</thead>
|
||||
}
|
||||
|
||||
templ Body(props ...BodyProps) {
|
||||
{{ var p BodyProps }}
|
||||
if len(props) > 0 {
|
||||
{{ p = props[0] }}
|
||||
}
|
||||
<tbody
|
||||
if p.ID != "" {
|
||||
id={ p.ID }
|
||||
}
|
||||
class={ utils.TwMerge("[&_tr:last-child]:border-0", p.Class) }
|
||||
{ p.Attributes... }
|
||||
>
|
||||
{ children... }
|
||||
</tbody>
|
||||
}
|
||||
|
||||
templ Footer(props ...FooterProps) {
|
||||
{{ var p FooterProps }}
|
||||
if len(props) > 0 {
|
||||
{{ p = props[0] }}
|
||||
}
|
||||
<tfoot
|
||||
if p.ID != "" {
|
||||
id={ p.ID }
|
||||
}
|
||||
class={ utils.TwMerge("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", p.Class) }
|
||||
{ p.Attributes... }
|
||||
>
|
||||
{ children... }
|
||||
</tfoot>
|
||||
}
|
||||
|
||||
templ Row(props ...RowProps) {
|
||||
{{ var p RowProps }}
|
||||
if len(props) > 0 {
|
||||
{{ p = props[0] }}
|
||||
}
|
||||
<tr
|
||||
if p.ID != "" {
|
||||
id={ p.ID }
|
||||
}
|
||||
class={
|
||||
utils.TwMerge(
|
||||
"border-b transition-colors hover:bg-muted/50",
|
||||
utils.If(p.Selected, "data-[tui-table-state-selected]:bg-muted"),
|
||||
p.Class,
|
||||
),
|
||||
}
|
||||
if p.Selected {
|
||||
data-tui-table-state-selected
|
||||
}
|
||||
{ p.Attributes... }
|
||||
>
|
||||
{ children... }
|
||||
</tr>
|
||||
}
|
||||
|
||||
templ Head(props ...HeadProps) {
|
||||
{{ var p HeadProps }}
|
||||
if len(props) > 0 {
|
||||
{{ p = props[0] }}
|
||||
}
|
||||
<th
|
||||
if p.ID != "" {
|
||||
id={ p.ID }
|
||||
}
|
||||
class={
|
||||
utils.TwMerge(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground",
|
||||
"[&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
p.Class,
|
||||
),
|
||||
}
|
||||
{ p.Attributes... }
|
||||
>
|
||||
{ children... }
|
||||
</th>
|
||||
}
|
||||
|
||||
templ Cell(props ...CellProps) {
|
||||
{{ var p CellProps }}
|
||||
if len(props) > 0 {
|
||||
{{ p = props[0] }}
|
||||
}
|
||||
<td
|
||||
if p.ID != "" {
|
||||
id={ p.ID }
|
||||
}
|
||||
class={
|
||||
utils.TwMerge(
|
||||
"p-2 align-middle",
|
||||
"[&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
p.Class,
|
||||
),
|
||||
}
|
||||
{ p.Attributes... }
|
||||
>
|
||||
{ children... }
|
||||
</td>
|
||||
}
|
||||
|
||||
templ Caption(props ...CaptionProps) {
|
||||
{{ var p CaptionProps }}
|
||||
if len(props) > 0 {
|
||||
{{ p = props[0] }}
|
||||
}
|
||||
<caption
|
||||
if p.ID != "" {
|
||||
id={ p.ID }
|
||||
}
|
||||
class={ utils.TwMerge("mt-4 text-sm text-muted-foreground", p.Class) }
|
||||
{ p.Attributes... }
|
||||
>
|
||||
{ children... }
|
||||
</caption>
|
||||
}
|
138
handlers/download.go
Normal file
138
handlers/download.go
Normal file
@ -0,0 +1,138 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/valkey-io/valkey-go"
|
||||
)
|
||||
|
||||
func DownloadFile(client valkey.Client) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
upload_id := r.PathValue("upload_id")
|
||||
if upload_id == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file_id := r.PathValue("file_id")
|
||||
if file_id == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
files_json, err := client.Do(r.Context(),
|
||||
client.B().Get().Key(upload_id).Build()).ToString()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var files []string
|
||||
err = json.Unmarshal([]byte(files_json), &files)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
found := false
|
||||
found_id := 0
|
||||
for i, id := range files {
|
||||
if id == file_id {
|
||||
found = true
|
||||
found_id = i
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
file := files[found_id]
|
||||
|
||||
filename, err := client.Do(r.Context(), client.B().Get().Key(file+":filename").Build()).ToString()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fileContents, err := client.Do(r.Context(), client.B().Get().Key(file+":contents").Build()).AsBytes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
w.Header().Add("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
mime := mimetype.Detect(fileContents)
|
||||
w.Header().Add("mimetype", mime.String())
|
||||
w.Write(fileContents)
|
||||
}
|
||||
}
|
||||
func DownloadFilesZipped(client valkey.Client) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
upload_id := r.PathValue("upload_id")
|
||||
if upload_id == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
files_json, err := client.Do(r.Context(),
|
||||
client.B().Get().Key(upload_id).Build()).ToString()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var files []string
|
||||
|
||||
err = json.Unmarshal([]byte(files_json), &files)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(files) == 1 {
|
||||
file := files[0]
|
||||
|
||||
filename, err := client.Do(r.Context(), client.B().Get().Key(file+":filename").Build()).ToString()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fileContents, err := client.Do(r.Context(), client.B().Get().Key(file+":contents").Build()).AsBytes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
w.Header().Add("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
mime := mimetype.Detect(fileContents)
|
||||
w.Header().Add("mimetype", mime.String())
|
||||
w.Write(fileContents)
|
||||
return
|
||||
}
|
||||
|
||||
zipBuf := new(bytes.Buffer)
|
||||
zipWriter := zip.NewWriter(zipBuf)
|
||||
|
||||
for _, file := range files {
|
||||
filename, err := client.Do(r.Context(), client.B().Get().Key(file+":filename").Build()).ToString()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fileContents, err := client.Do(r.Context(), client.B().Get().Key(file+":contents").Build()).AsBytes()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
w, err := zipWriter.Create(filename)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
w.Write(fileContents)
|
||||
|
||||
}
|
||||
|
||||
zipWriter.Close()
|
||||
w.Header().Add("mimetype", "application/zip")
|
||||
w.Header().Add("Content-Disposition", `attachment; filename="timeshare-download.zip"`)
|
||||
io.Copy(w, zipBuf)
|
||||
|
||||
}
|
||||
}
|
58
handlers/list.go
Normal file
58
handlers/list.go
Normal file
@ -0,0 +1,58 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.hafen.run/lukas/timeshare/pages"
|
||||
"github.com/a-h/templ"
|
||||
"github.com/valkey-io/valkey-go"
|
||||
)
|
||||
|
||||
func ListFiles(client valkey.Client) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
upload_id := r.PathValue("upload_id")
|
||||
if upload_id == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
expiresInt, err := client.Do(r.Context(), client.B().Ttl().Key(upload_id).Build()).AsInt64()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
expiresIn := time.Second * time.Duration(expiresInt)
|
||||
expires := time.Now().Add(expiresIn)
|
||||
|
||||
files_json, err := client.Do(r.Context(),
|
||||
client.B().Get().Key(upload_id).Build()).ToString()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var fileIds []string
|
||||
|
||||
err = json.Unmarshal([]byte(files_json), &fileIds)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
files := make([]pages.File, len(fileIds))
|
||||
for i, fileid := range fileIds {
|
||||
filename, err := client.Do(r.Context(), client.B().Get().Key(fileid+":filename").Build()).ToString()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
files[i] = pages.File{
|
||||
Filename: filename,
|
||||
Key: fileid,
|
||||
}
|
||||
}
|
||||
|
||||
templ.Handler(pages.ListShareContents(expires, upload_id, files)).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
69
handlers/upload.go
Normal file
69
handlers/upload.go
Normal file
@ -0,0 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.hafen.run/lukas/timeshare/pages"
|
||||
"github.com/a-h/templ"
|
||||
"github.com/google/uuid"
|
||||
"github.com/valkey-io/valkey-go"
|
||||
)
|
||||
|
||||
func UploadFiles(client valkey.Client) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseMultipartForm(125_000_000) // 1G max memory usage
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := r.MultipartForm
|
||||
expiry_values := form.Value["expiry"]
|
||||
expiry, err := time.ParseDuration(expiry_values[0])
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form_files := form.File["files"]
|
||||
files := make([]string, len(form_files))
|
||||
for i := range form_files {
|
||||
fid := uuid.New().String()
|
||||
files[i] = fid
|
||||
f, err := form_files[i].Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
_, err = io.Copy(buf, f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
err = client.Do(r.Context(), client.B().Set().Key(fid+":filename").Value(form_files[i].Filename).Ex(expiry).Build()).Error()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
err = client.Do(r.Context(), client.B().Set().Key(fid+":contents").Value(valkey.BinaryString(buf.Bytes())).Ex(expiry).Build()).Error()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
uid := uuid.New()
|
||||
files_b, _ := json.Marshal(files)
|
||||
err = client.Do(r.Context(), client.B().Set().Key(uid.String()).Value(string(files_b)).Ex(expiry).Build()).Error()
|
||||
|
||||
templ.Handler(pages.Upload([]pages.Expiry{
|
||||
{DurationCode: "24h", DurationName: "24 Hours"},
|
||||
{DurationCode: "36h", DurationName: "36 Hours"},
|
||||
{DurationCode: "48h", DurationName: "48 Hours"},
|
||||
{DurationCode: "168h", DurationName: "1 Week"},
|
||||
}, fmt.Sprintf("https://share.lukaswerner.com/%s", uid.String()))).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
132
main.go
132
main.go
@ -1,22 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.hafen.run/lukas/timeshare/handlers"
|
||||
"git.hafen.run/lukas/timeshare/pages"
|
||||
"github.com/a-h/templ"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/google/uuid"
|
||||
"github.com/valkey-io/valkey-go"
|
||||
)
|
||||
|
||||
@ -46,125 +39,10 @@ func main() {
|
||||
{DurationCode: "168h", DurationName: "1 Week"},
|
||||
}, "")))
|
||||
|
||||
http.Handle("POST /upload", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseMultipartForm(125_000_000) // 1G max memory usage
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := r.MultipartForm
|
||||
expiry_values := form.Value["expiry"]
|
||||
expiry, err := time.ParseDuration(expiry_values[0])
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form_files := form.File["files"]
|
||||
files := make([]string, len(form_files))
|
||||
for i := range form_files {
|
||||
fid := uuid.New().String()
|
||||
files[i] = fid
|
||||
f, err := form_files[i].Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
_, err = io.Copy(buf, f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
err = client.Do(r.Context(), client.B().Set().Key(fid+":filename").Value(form_files[i].Filename).Ex(expiry).Build()).Error()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
err = client.Do(r.Context(), client.B().Set().Key(fid+":contents").Value(valkey.BinaryString(buf.Bytes())).Ex(expiry).Build()).Error()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
uid := uuid.New()
|
||||
files_b, _ := json.Marshal(files)
|
||||
err = client.Do(r.Context(), client.B().Set().Key(uid.String()).Value(string(files_b)).Ex(expiry).Build()).Error()
|
||||
|
||||
templ.Handler(pages.Upload([]pages.Expiry{
|
||||
{DurationCode: "24h", DurationName: "24 Hours"},
|
||||
{DurationCode: "36h", DurationName: "36 Hours"},
|
||||
{DurationCode: "48h", DurationName: "48 Hours"},
|
||||
{DurationCode: "168h", DurationName: "1 Week"},
|
||||
}, fmt.Sprintf("https://share.lukaswerner.com/%s", uid.String()))).ServeHTTP(w, r)
|
||||
}))
|
||||
|
||||
http.Handle("/{upload_id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upload_id := r.PathValue("upload_id")
|
||||
if upload_id == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
files_json, err := client.Do(r.Context(),
|
||||
client.B().Get().Key(upload_id).Build()).ToString()
|
||||
if err != nil {
|
||||
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var files []string
|
||||
|
||||
err = json.Unmarshal([]byte(files_json), &files)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(files) == 1 {
|
||||
file := files[0]
|
||||
|
||||
filename, err := client.Do(r.Context(), client.B().Get().Key(file+":filename").Build()).ToString()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fileContents, err := client.Do(r.Context(), client.B().Get().Key(file+":contents").Build()).AsBytes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
w.Header().Add("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
mime := mimetype.Detect(fileContents)
|
||||
w.Header().Add("mimetype", mime.String())
|
||||
w.Write(fileContents)
|
||||
return
|
||||
}
|
||||
|
||||
zipBuf := new(bytes.Buffer)
|
||||
zipWriter := zip.NewWriter(zipBuf)
|
||||
|
||||
for _, file := range files {
|
||||
filename, err := client.Do(r.Context(), client.B().Get().Key(file+":filename").Build()).ToString()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fileContents, err := client.Do(r.Context(), client.B().Get().Key(file+":contents").Build()).AsBytes()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
w, err := zipWriter.Create(filename)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
w.Write(fileContents)
|
||||
|
||||
}
|
||||
|
||||
zipWriter.Close()
|
||||
w.Header().Add("mimetype", "application/zip")
|
||||
w.Header().Add("Content-Disposition", `attachment; filename="timeshare-download.zip"`)
|
||||
io.Copy(w, zipBuf)
|
||||
|
||||
}))
|
||||
http.Handle("POST /upload", http.HandlerFunc(handlers.UploadFiles(client)))
|
||||
http.Handle("/download/{upload_id}/zip", http.HandlerFunc(handlers.DownloadFilesZipped(client)))
|
||||
http.Handle("/download/{upload_id}/{file_id}", http.HandlerFunc(handlers.DownloadFile(client)))
|
||||
http.Handle("/{upload_id}", http.HandlerFunc(handlers.ListFiles(client)))
|
||||
http.Handle("/", templ.Handler(pages.Index()))
|
||||
log.Println(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
|
2
makefile
2
makefile
@ -1,6 +1,6 @@
|
||||
# Run templ generation in watch mode
|
||||
templ:
|
||||
templ generate --watch --proxy="http://localhost:8090" --open-browser=false
|
||||
templ generate --watch --proxy="http://localhost:8080" --open-browser=true
|
||||
|
||||
# Run air for Go hot reload
|
||||
server:
|
||||
|
143
pages/share_list.templ
Normal file
143
pages/share_list.templ
Normal file
@ -0,0 +1,143 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"git.hafen.run/lukas/timeshare/components/badge"
|
||||
"git.hafen.run/lukas/timeshare/components/button"
|
||||
"git.hafen.run/lukas/timeshare/components/checkbox"
|
||||
"git.hafen.run/lukas/timeshare/components/icon"
|
||||
"git.hafen.run/lukas/timeshare/components/table"
|
||||
"path"
|
||||
"time"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
Filename string
|
||||
Key string
|
||||
}
|
||||
|
||||
templ filetypeIcon(filename string) {
|
||||
switch path.Ext(filename) {
|
||||
case ".jpg",".jpeg", ".png",".heic", ".webp", ".avif", ".gif", ".tiff":
|
||||
@icon.FileImage()
|
||||
case ".mp3",".flac", ".m4a",".m4b", ".wav", ".midi":
|
||||
@icon.FileAudio()
|
||||
case ".mov",".mp4":
|
||||
@icon.FileVideo()
|
||||
case ".txt",".md":
|
||||
@icon.FileText()
|
||||
case ".zip":
|
||||
@icon.FileArchive()
|
||||
case ".xlsx", ".csv":
|
||||
@icon.FileSpreadsheet()
|
||||
case ".pdf", ".docx", ".doc":
|
||||
@icon.FileText()
|
||||
case ".epub":
|
||||
@icon.BookMarked()
|
||||
case ".stl", ".3mf", ".step":
|
||||
@icon.FileAxis3d()
|
||||
case ".json":
|
||||
@icon.FileJson()
|
||||
default:
|
||||
@icon.File()
|
||||
}
|
||||
}
|
||||
|
||||
templ ListShareContents(expires time.Time, shareid string, files []File) {
|
||||
@PageSkeleton("Download - Time Share") {
|
||||
<div class="flex flex-col p-10 w-full">
|
||||
<div class="flex justify-between">
|
||||
<div class="sm:flex gap-2 items-center">
|
||||
<h1 class="text-2xl">Download from Share</h1>
|
||||
@badge.Badge(badge.Props{
|
||||
Class: "flex gap-2 items-center dark:text-white",
|
||||
}) {
|
||||
@icon.Timer()
|
||||
{ expires.Format("03:04PM Jan 2") }
|
||||
}
|
||||
</div>
|
||||
<a href={ "/download/" + shareid + "/zip" } id="download-link">
|
||||
@button.Button(button.Props{ID: "download-button"}) {
|
||||
@icon.FolderDown()
|
||||
Download All
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<div class="w-full md:w-3/4">
|
||||
@table.Table() {
|
||||
@table.Header() {
|
||||
@table.Row() {
|
||||
@table.Head()
|
||||
@table.Head() {
|
||||
Filename
|
||||
}
|
||||
}
|
||||
}
|
||||
@table.Body() {
|
||||
for _, file := range files {
|
||||
@table.Row() {
|
||||
@table.Cell(table.CellProps{Class: "w-2"}) {
|
||||
@checkbox.Checkbox(checkbox.Props{
|
||||
Name: file.Key,
|
||||
Value: file.Key,
|
||||
})
|
||||
}
|
||||
@table.Cell() {
|
||||
<span class="flex gap-2 items-center">
|
||||
@filetypeIcon(file.Filename)
|
||||
{ file.Filename }
|
||||
</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let checkedIds = [];
|
||||
let downloadButton;
|
||||
let downloadLink;
|
||||
let downloadLinkZip;
|
||||
let downloadIcon;
|
||||
let share_id;
|
||||
window.onload = () => {
|
||||
downloadButton = document.querySelector("#download-button");
|
||||
downloadLink = document.querySelector("#download-link");
|
||||
downloadLinkZip = document.querySelector("#download-link").href;
|
||||
downloadIcon = downloadLink.querySelector("svg").outerHTML;
|
||||
share_id = window.location.pathname;
|
||||
};
|
||||
document.querySelectorAll("input[type=checkbox]").forEach(el=>{
|
||||
el.addEventListener("click", el=>{
|
||||
checkedIds = Array.from(document.querySelectorAll("input[type=checkbox]:checked").values().map(el=>el.value))
|
||||
if (checkedIds.length == 0) {
|
||||
// nothing selected. download all
|
||||
downloadButton.innerHTML = downloadIcon + "Download All";
|
||||
downloadLink.href = downloadLinkZip;
|
||||
downloadButton.onclick = () => {};
|
||||
} else {
|
||||
// things selected!
|
||||
downloadButton.innerHTML = downloadIcon + "Download Selected";
|
||||
downloadLink.href = "#";
|
||||
downloadButton.onclick = () => {
|
||||
let delay = 0;
|
||||
for (const id of checkedIds) {
|
||||
console.log(id);
|
||||
const a = document.createElement("a");
|
||||
a.href = "/download" + share_id + "/" + id;
|
||||
document.body.appendChild(a);
|
||||
setTimeout(() => {
|
||||
a.click();
|
||||
a.remove();
|
||||
}, delay);
|
||||
delay += 200;
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
}
|
||||
}
|
@ -37,6 +37,16 @@ templ Upload(expirations []Expiry, uploadedLink string) {
|
||||
let drop_zone = document.getElementById("drop_zone");
|
||||
let desc = document.getElementById("desc");
|
||||
let fileInput = document.getElementById("files");
|
||||
function updateDescription(len) {
|
||||
desc.innerText = `${len} File`
|
||||
if (len > 1) {
|
||||
desc.innerText += "s"
|
||||
}
|
||||
desc.innerText += " Attached"
|
||||
}
|
||||
fileInput.addEventListener("change", () => {
|
||||
updateDescription(fileInput.files.length)
|
||||
});
|
||||
drop_zone.addEventListener("click", function (ev) {
|
||||
fileInput.click();
|
||||
})
|
||||
@ -51,11 +61,7 @@ templ Upload(expirations []Expiry, uploadedLink string) {
|
||||
const newDT = new DataTransfer();
|
||||
files.forEach(f => newDT.items.add(f));
|
||||
fileInput.files = newDT.files;
|
||||
desc.innerText = `${fileInput.files.length} File`
|
||||
if (fileInput.files.length > 1) {
|
||||
desc.innerText += "s"
|
||||
}
|
||||
|
||||
updateDescription(fileInput.files.length)
|
||||
});
|
||||
drop_zone.addEventListener("dragover", function (e) {
|
||||
drop_zone.classList.add('dragover');
|
||||
|
BIN
tmp/bin/main
Executable file
BIN
tmp/bin/main
Executable file
Binary file not shown.
1
tmp/build-errors.log
Normal file
1
tmp/build-errors.log
Normal file
@ -0,0 +1 @@
|
||||
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1
|
Loading…
x
Reference in New Issue
Block a user