feat: add download page

This commit is contained in:
Lukas Werner 2025-08-30 22:14:57 -07:00
parent efee85cf31
commit ee21b17935
No known key found for this signature in database
13 changed files with 1039 additions and 201 deletions

View File

@ -11,14 +11,18 @@
--color-yellow-500: oklch(79.5% 0.184 86.047); --color-yellow-500: oklch(79.5% 0.184 86.047);
--color-green-500: oklch(72.3% 0.219 149.579); --color-green-500: oklch(72.3% 0.219 149.579);
--color-blue-500: oklch(62.3% 0.214 259.815); --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-300: oklch(87.2% 0.01 258.338);
--color-gray-400: oklch(70.7% 0.022 261.325); --color-gray-400: oklch(70.7% 0.022 261.325);
--color-gray-500: oklch(55.1% 0.027 264.364); --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; --spacing: 0.25rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem; --text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875); --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: 1.5rem;
--text-2xl--line-height: calc(2 / 1.5); --text-2xl--line-height: calc(2 / 1.5);
--text-3xl: 1.875rem; --text-3xl: 1.875rem;
@ -31,7 +35,6 @@
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--default-transition-duration: 150ms; --default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans); --default-font-family: var(--font-sans);
@ -228,6 +231,9 @@
.pointer-events-auto { .pointer-events-auto {
pointer-events: auto; pointer-events: auto;
} }
.pointer-events-none {
pointer-events: none;
}
.collapse { .collapse {
visibility: collapse; visibility: collapse;
} }
@ -240,27 +246,15 @@
.relative { .relative {
position: relative; position: relative;
} }
.inset-0 {
inset: calc(var(--spacing) * 0);
}
.top-0 { .top-0 {
top: calc(var(--spacing) * 0); top: calc(var(--spacing) * 0);
} }
.right-0 { .right-0 {
right: calc(var(--spacing) * 0); right: calc(var(--spacing) * 0);
} }
.bottom-0 {
bottom: calc(var(--spacing) * 0);
}
.left-0 { .left-0 {
left: calc(var(--spacing) * 0); left: calc(var(--spacing) * 0);
} }
.left-1 {
left: calc(var(--spacing) * 1);
}
.left-1\/2 {
left: calc(1/2 * 100%);
}
.z-50 { .z-50 {
z-index: 50; z-index: 50;
} }
@ -294,6 +288,9 @@
.mt-1 { .mt-1 {
margin-top: calc(var(--spacing) * 1); margin-top: calc(var(--spacing) * 1);
} }
.mt-4 {
margin-top: calc(var(--spacing) * 4);
}
.mr-3 { .mr-3 {
margin-right: calc(var(--spacing) * 3); margin-right: calc(var(--spacing) * 3);
} }
@ -318,6 +315,10 @@
.table { .table {
display: table; display: table;
} }
.size-4 {
width: calc(var(--spacing) * 4);
height: calc(var(--spacing) * 4);
}
.size-9 { .size-9 {
width: calc(var(--spacing) * 9); width: calc(var(--spacing) * 9);
height: calc(var(--spacing) * 9); height: calc(var(--spacing) * 9);
@ -343,15 +344,27 @@
.h-screen { .h-screen {
height: 100vh; height: 100vh;
} }
.w-2 {
width: calc(var(--spacing) * 2);
}
.w-3 {
width: calc(var(--spacing) * 3);
}
.w-4 { .w-4 {
width: calc(var(--spacing) * 4); width: calc(var(--spacing) * 4);
} }
.w-7 { .w-9 {
width: calc(var(--spacing) * 7); width: calc(var(--spacing) * 9);
}
.w-40 {
width: calc(var(--spacing) * 40);
} }
.w-\[39ch\] { .w-\[39ch\] {
width: 39ch; width: 39ch;
} }
.w-fit {
width: fit-content;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
@ -379,28 +392,15 @@
.grow { .grow {
flex-grow: 1; flex-grow: 1;
} }
.caption-bottom {
caption-side: bottom;
}
.border-collapse { .border-collapse {
border-collapse: collapse; border-collapse: collapse;
} }
.origin-left { .origin-left {
transform-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-3d {
scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z); scale: var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z);
} }
@ -437,6 +437,9 @@
.items-start { .items-start {
align-items: flex-start; align-items: flex-start;
} }
.justify-between {
justify-content: space-between;
}
.justify-center { .justify-center {
justify-content: center; justify-content: center;
} }
@ -452,17 +455,20 @@
.gap-3 { .gap-3 {
gap: calc(var(--spacing) * 3); gap: calc(var(--spacing) * 3);
} }
.gap-6 {
gap: calc(var(--spacing) * 6);
}
.truncate { .truncate {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.overflow-auto {
overflow: auto;
}
.overflow-hidden { .overflow-hidden {
overflow: hidden; overflow: hidden;
} }
.rounded-\[4px\] {
border-radius: 4px;
}
.rounded-full { .rounded-full {
border-radius: calc(infinity * 1px); border-radius: calc(infinity * 1px);
} }
@ -483,6 +489,14 @@
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 2px; 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 { .border-dashed {
--tw-border-style: dashed; --tw-border-style: dashed;
border-style: dashed; border-style: dashed;
@ -490,27 +504,42 @@
.border-gray-400 { .border-gray-400 {
border-color: var(--color-gray-400); border-color: var(--color-gray-400);
} }
.border-input {
border-color: var(--input);
}
.border-primary { .border-primary {
border-color: var(--primary); border-color: var(--primary);
} }
.border-transparent {
border-color: transparent;
}
.bg-background { .bg-background {
background-color: var(--background); background-color: var(--background);
} }
.bg-blue-500 {
background-color: var(--color-blue-500);
}
.bg-destructive { .bg-destructive {
background-color: var(--destructive); background-color: var(--destructive);
} }
.bg-gray-300 { .bg-gray-300 {
background-color: var(--color-gray-300); background-color: var(--color-gray-300);
} }
.bg-gray-500 { .bg-gray-800 {
background-color: var(--color-gray-500); background-color: var(--color-gray-800);
}
.bg-gray-950 {
background-color: var(--color-gray-950);
} }
.bg-green-500 { .bg-green-500 {
background-color: var(--color-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 { .bg-popover {
background-color: var(--popover); background-color: var(--popover);
} }
@ -526,8 +555,8 @@
.bg-selection { .bg-selection {
background-color: var(--selection); background-color: var(--selection);
} }
.bg-yellow-500 { .bg-white {
background-color: var(--color-yellow-500); background-color: var(--color-white);
} }
.p-0 { .p-0 {
padding: calc(var(--spacing) * 0); padding: calc(var(--spacing) * 0);
@ -541,12 +570,21 @@
.p-4 { .p-4 {
padding: calc(var(--spacing) * 4); padding: calc(var(--spacing) * 4);
} }
.p-5 {
padding: calc(var(--spacing) * 5);
}
.p-7 { .p-7 {
padding: calc(var(--spacing) * 7); padding: calc(var(--spacing) * 7);
} }
.p-8 {
padding: calc(var(--spacing) * 8);
}
.p-10 { .p-10 {
padding: calc(var(--spacing) * 10); padding: calc(var(--spacing) * 10);
} }
.px-2 {
padding-inline: calc(var(--spacing) * 2);
}
.px-3 { .px-3 {
padding-inline: calc(var(--spacing) * 3); padding-inline: calc(var(--spacing) * 3);
} }
@ -556,6 +594,12 @@
.px-6 { .px-6 {
padding-inline: calc(var(--spacing) * 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 { .py-2 {
padding-block: calc(var(--spacing) * 2); padding-block: calc(var(--spacing) * 2);
} }
@ -568,12 +612,15 @@
.pl-2 { .pl-2 {
padding-left: calc(var(--spacing) * 2); padding-left: calc(var(--spacing) * 2);
} }
.pl-4 {
padding-left: calc(var(--spacing) * 4);
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
.text-left {
text-align: left;
}
.align-middle {
vertical-align: middle;
}
.text-2xl { .text-2xl {
font-size: var(--text-2xl); font-size: var(--text-2xl);
line-height: var(--tw-leading, var(--text-2xl--line-height)); line-height: var(--tw-leading, var(--text-2xl--line-height));
@ -590,6 +637,10 @@
font-size: var(--text-sm); font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height)); 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 { .leading-none {
--tw-leading: 1; --tw-leading: 1;
line-height: 1; line-height: 1;
@ -611,9 +662,15 @@
.text-destructive { .text-destructive {
color: var(--destructive); color: var(--destructive);
} }
.text-foreground {
color: var(--foreground);
}
.text-green-500 { .text-green-500 {
color: var(--color-green-500); color: var(--color-green-500);
} }
.text-muted-foreground {
color: var(--muted-foreground);
}
.text-popover-foreground { .text-popover-foreground {
color: var(--popover-foreground); color: var(--popover-foreground);
} }
@ -629,6 +686,9 @@
.text-secondary-foreground { .text-secondary-foreground {
color: var(--secondary-foreground); color: var(--secondary-foreground);
} }
.text-white {
color: var(--color-white);
}
.text-yellow-500 { .text-yellow-500 {
color: var(--color-yellow-500); color: var(--color-yellow-500);
} }
@ -670,11 +730,26 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration)); 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-all {
transition-property: all; transition-property: all;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration)); 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-transform {
transition-property: transform, translate, scale, rotate; transition-property: transform, translate, scale, rotate;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@ -688,14 +763,15 @@
--tw-ease: linear; --tw-ease: linear;
transition-timing-function: linear; transition-timing-function: linear;
} }
.ease-out {
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
}
.outline-none { .outline-none {
--tw-outline-style: none; --tw-outline-style: none;
outline-style: none; outline-style: none;
} }
.peer-checked\:opacity-100 {
&:is(:where(.peer):checked ~ *) {
opacity: 100%;
}
}
.before\:absolute { .before\:absolute {
&::before { &::before {
content: var(--tw-content); content: var(--tw-content);
@ -762,6 +838,11 @@
background-color: var(--primary); background-color: var(--primary);
} }
} }
.checked\:text-primary-foreground {
&:checked {
color: var(--primary-foreground);
}
}
.checked\:before\:visible { .checked\:before\:visible {
&:checked { &:checked {
&::before { &::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\:bg-primary\/90 {
&:hover { &:hover {
@media (hover: 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 {
&:disabled { &:disabled {
pointer-events: none; pointer-events: none;
@ -1009,6 +1106,11 @@
right: calc(var(--spacing) * 0); 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\]\:bg-gray-500 {
&[data-variant="default"] { &[data-variant="default"] {
background-color: var(--color-gray-500); background-color: var(--color-gray-500);
@ -1034,6 +1136,16 @@
background-color: var(--color-yellow-500); 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\] { .md\:max-w-\[420px\] {
@media (width >= 48rem) { @media (width >= 48rem) {
max-width: 420px; max-width: 420px;
@ -1060,6 +1172,11 @@
} }
} }
} }
.dark\:text-white {
&:where(.dark, .dark *) {
color: var(--color-white);
}
}
.dark\:hover\:bg-accent\/50 { .dark\:hover\:bg-accent\/50 {
&:where(.dark, .dark *) { &:where(.dark, .dark *) {
&:hover { &:hover {
@ -1120,6 +1237,102 @@
height: calc(var(--spacing) * 4); 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 { :root {
--radius: 0.65rem; --radius: 0.65rem;
@ -1207,21 +1420,6 @@
font-feature-settings: "rlig" 1, "calt" 1; 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 { @property --tw-scale-x {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@ -1353,12 +1551,24 @@
initial-value: ""; initial-value: "";
inherits: false; 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 { @layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { @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 { *, ::before, ::after, ::backdrop {
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-translate-z: 0;
--tw-scale-x: 1; --tw-scale-x: 1;
--tw-scale-y: 1; --tw-scale-y: 1;
--tw-scale-z: 1; --tw-scale-z: 1;
@ -1388,6 +1598,9 @@
--tw-duration: initial; --tw-duration: initial;
--tw-ease: initial; --tw-ease: initial;
--tw-content: ""; --tw-content: "";
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-translate-z: 0;
} }
} }
} }

View 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"
}
}

View 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>
}

View 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
View 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
View 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
View 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
View File

@ -1,22 +1,15 @@
package main package main
import ( import (
"archive/zip"
"bytes"
"embed" "embed"
"encoding/json"
"fmt"
"io"
"log" "log"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"os" "os"
"time"
"git.hafen.run/lukas/timeshare/handlers"
"git.hafen.run/lukas/timeshare/pages" "git.hafen.run/lukas/timeshare/pages"
"github.com/a-h/templ" "github.com/a-h/templ"
"github.com/gabriel-vasile/mimetype"
"github.com/google/uuid"
"github.com/valkey-io/valkey-go" "github.com/valkey-io/valkey-go"
) )
@ -46,125 +39,10 @@ func main() {
{DurationCode: "168h", DurationName: "1 Week"}, {DurationCode: "168h", DurationName: "1 Week"},
}, ""))) }, "")))
http.Handle("POST /upload", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Handle("POST /upload", http.HandlerFunc(handlers.UploadFiles(client)))
err := r.ParseMultipartForm(125_000_000) // 1G max memory usage http.Handle("/download/{upload_id}/zip", http.HandlerFunc(handlers.DownloadFilesZipped(client)))
if err != nil { http.Handle("/download/{upload_id}/{file_id}", http.HandlerFunc(handlers.DownloadFile(client)))
http.Error(w, err.Error(), http.StatusBadRequest) http.Handle("/{upload_id}", http.HandlerFunc(handlers.ListFiles(client)))
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("/", templ.Handler(pages.Index())) http.Handle("/", templ.Handler(pages.Index()))
log.Println(http.ListenAndServe(":8080", nil)) log.Println(http.ListenAndServe(":8080", nil))
} }

View File

@ -1,6 +1,6 @@
# Run templ generation in watch mode # Run templ generation in watch mode
templ: 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 # Run air for Go hot reload
server: server:

143
pages/share_list.templ Normal file
View 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>
}
}

View File

@ -37,6 +37,16 @@ templ Upload(expirations []Expiry, uploadedLink string) {
let drop_zone = document.getElementById("drop_zone"); let drop_zone = document.getElementById("drop_zone");
let desc = document.getElementById("desc"); let desc = document.getElementById("desc");
let fileInput = document.getElementById("files"); 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) { drop_zone.addEventListener("click", function (ev) {
fileInput.click(); fileInput.click();
}) })
@ -51,11 +61,7 @@ templ Upload(expirations []Expiry, uploadedLink string) {
const newDT = new DataTransfer(); const newDT = new DataTransfer();
files.forEach(f => newDT.items.add(f)); files.forEach(f => newDT.items.add(f));
fileInput.files = newDT.files; fileInput.files = newDT.files;
desc.innerText = `${fileInput.files.length} File` updateDescription(fileInput.files.length)
if (fileInput.files.length > 1) {
desc.innerText += "s"
}
}); });
drop_zone.addEventListener("dragover", function (e) { drop_zone.addEventListener("dragover", function (e) {
drop_zone.classList.add('dragover'); drop_zone.classList.add('dragover');

BIN
tmp/bin/main Executable file

Binary file not shown.

1
tmp/build-errors.log Normal file
View 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