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] }} + } +
+ + { children... } +
+
+} + +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") { +
+
+
+

Download from Share

+ @badge.Badge(badge.Props{ + Class: "flex gap-2 items-center dark:text-white", + }) { + @icon.Timer() + { expires.Format("03:04PM Jan 2") } + } +
+ + @button.Button(button.Props{ID: "download-button"}) { + @icon.FolderDown() + Download All + } + +
+
+
+ @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