diff --git a/assets/css/output.css b/assets/css/output.css
index d7c7d7e..f251794 100644
--- a/assets/css/output.css
+++ b/assets/css/output.css
@@ -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;
}
}
}
diff --git a/components/badge/badge.templ b/components/badge/badge.templ
new file mode 100644
index 0000000..770f15d
--- /dev/null
+++ b/components/badge/badge.templ
@@ -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] }}
+ }
+ 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... }
+
+}
+
+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"
+ }
+}
diff --git a/components/checkbox/checkbox.templ b/components/checkbox/checkbox.templ
new file mode 100644
index 0000000..4c58ea4
--- /dev/null
+++ b/components/checkbox/checkbox.templ
@@ -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] }}
+ }
+
+
+
+ if p.Icon != nil {
+ @p.Icon
+ } else {
+ @icon.Check(icon.Props{Size: 14})
+ }
+
+
+}
diff --git a/components/table/table.templ b/components/table/table.templ
new file mode 100644
index 0000000..0573056
--- /dev/null
+++ b/components/table/table.templ
@@ -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] }}
+ }
+
+}
+
+templ Header(props ...HeaderProps) {
+ {{ var p HeaderProps }}
+ if len(props) > 0 {
+ {{ p = props[0] }}
+ }
+
+ { children... }
+
+}
+
+templ Body(props ...BodyProps) {
+ {{ var p BodyProps }}
+ if len(props) > 0 {
+ {{ p = props[0] }}
+ }
+
+ { children... }
+
+}
+
+templ Footer(props ...FooterProps) {
+ {{ var p FooterProps }}
+ if len(props) > 0 {
+ {{ p = props[0] }}
+ }
+ tr]:last:border-b-0", p.Class) }
+ { p.Attributes... }
+ >
+ { children... }
+
+}
+
+templ Row(props ...RowProps) {
+ {{ var p RowProps }}
+ if len(props) > 0 {
+ {{ p = props[0] }}
+ }
+
+ { children... }
+
+}
+
+templ Head(props ...HeadProps) {
+ {{ var p HeadProps }}
+ if len(props) > 0 {
+ {{ p = props[0] }}
+ }
+ [role=checkbox]]:translate-y-[2px]",
+ p.Class,
+ ),
+ }
+ { p.Attributes... }
+ >
+ { children... }
+ |
+}
+
+templ Cell(props ...CellProps) {
+ {{ var p CellProps }}
+ if len(props) > 0 {
+ {{ p = props[0] }}
+ }
+ [role=checkbox]]:translate-y-[2px]",
+ p.Class,
+ ),
+ }
+ { p.Attributes... }
+ >
+ { children... }
+ |
+}
+
+templ Caption(props ...CaptionProps) {
+ {{ var p CaptionProps }}
+ if len(props) > 0 {
+ {{ p = props[0] }}
+ }
+
+ { children... }
+
+}
diff --git a/handlers/download.go b/handlers/download.go
new file mode 100644
index 0000000..f6f14fe
--- /dev/null
+++ b/handlers/download.go
@@ -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)
+
+ }
+}
diff --git a/handlers/list.go b/handlers/list.go
new file mode 100644
index 0000000..f870806
--- /dev/null
+++ b/handlers/list.go
@@ -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)
+ }
+}
diff --git a/handlers/upload.go b/handlers/upload.go
new file mode 100644
index 0000000..29bde22
--- /dev/null
+++ b/handlers/upload.go
@@ -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)
+ }
+}
diff --git a/main.go b/main.go
index c2b92ab..8bb65bb 100644
--- a/main.go
+++ b/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))
}
diff --git a/makefile b/makefile
index 1b19fbf..71e919c 100644
--- a/makefile
+++ b/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:
diff --git a/pages/share_list.templ b/pages/share_list.templ
new file mode 100644
index 0000000..690e9a0
--- /dev/null
+++ b/pages/share_list.templ
@@ -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") {
+
+
+
+
+ @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() {
+
+ @filetypeIcon(file.Filename)
+ { file.Filename }
+
+ }
+ }
+ }
+ }
+ }
+
+
+
+
+ }
+}
diff --git a/pages/upload.templ b/pages/upload.templ
index 94fece7..aa64f89 100644
--- a/pages/upload.templ
+++ b/pages/upload.templ
@@ -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');
diff --git a/tmp/bin/main b/tmp/bin/main
new file mode 100755
index 0000000..77c3a20
Binary files /dev/null and b/tmp/bin/main differ
diff --git a/tmp/build-errors.log b/tmp/build-errors.log
new file mode 100644
index 0000000..f50898d
--- /dev/null
+++ b/tmp/build-errors.log
@@ -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
\ No newline at end of file