feat: initial commit
This commit is contained in:
		
						commit
						efee85cf31
					
				
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					*_templ.go
 | 
				
			||||||
 | 
					config.toml
 | 
				
			||||||
							
								
								
									
										7
									
								
								.templui.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.templui.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "componentsDir": "components",
 | 
				
			||||||
 | 
					  "utilsDir": "utils",
 | 
				
			||||||
 | 
					  "moduleName": "git.hafen.run/lukas/timeshare",
 | 
				
			||||||
 | 
					  "jsDir": "assets/js",
 | 
				
			||||||
 | 
					  "jsPublicPath": "/assets/js"
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										142
									
								
								assets/css/input.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								assets/css/input.css
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,142 @@
 | 
				
			|||||||
 | 
					@import 'tailwindcss';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@custom-variant dark (&:where(.dark, .dark *));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@theme inline {
 | 
				
			||||||
 | 
					  --breakpoint-3xl: 1600px;
 | 
				
			||||||
 | 
					  --breakpoint-4xl: 2000px;
 | 
				
			||||||
 | 
					  --radius-sm: calc(var(--radius) - 4px);
 | 
				
			||||||
 | 
					  --radius-md: calc(var(--radius) - 2px);
 | 
				
			||||||
 | 
					  --radius-lg: var(--radius);
 | 
				
			||||||
 | 
					  --radius-xl: calc(var(--radius) + 4px);
 | 
				
			||||||
 | 
					  --color-background: var(--background);
 | 
				
			||||||
 | 
					  --color-foreground: var(--foreground);
 | 
				
			||||||
 | 
					  --color-card: var(--card);
 | 
				
			||||||
 | 
					  --color-card-foreground: var(--card-foreground);
 | 
				
			||||||
 | 
					  --color-popover: var(--popover);
 | 
				
			||||||
 | 
					  --color-popover-foreground: var(--popover-foreground);
 | 
				
			||||||
 | 
					  --color-primary: var(--primary);
 | 
				
			||||||
 | 
					  --color-primary-foreground: var(--primary-foreground);
 | 
				
			||||||
 | 
					  --color-secondary: var(--secondary);
 | 
				
			||||||
 | 
					  --color-secondary-foreground: var(--secondary-foreground);
 | 
				
			||||||
 | 
					  --color-muted: var(--muted);
 | 
				
			||||||
 | 
					  --color-muted-foreground: var(--muted-foreground);
 | 
				
			||||||
 | 
					  --color-accent: var(--accent);
 | 
				
			||||||
 | 
					  --color-accent-foreground: var(--accent-foreground);
 | 
				
			||||||
 | 
					  --color-destructive: var(--destructive);
 | 
				
			||||||
 | 
					  --color-border: var(--border);
 | 
				
			||||||
 | 
					  --color-input: var(--input);
 | 
				
			||||||
 | 
					  --color-ring: var(--ring);
 | 
				
			||||||
 | 
					  --color-chart-1: var(--chart-1);
 | 
				
			||||||
 | 
					  --color-chart-2: var(--chart-2);
 | 
				
			||||||
 | 
					  --color-chart-3: var(--chart-3);
 | 
				
			||||||
 | 
					  --color-chart-4: var(--chart-4);
 | 
				
			||||||
 | 
					  --color-chart-5: var(--chart-5);
 | 
				
			||||||
 | 
					  --color-sidebar: var(--sidebar);
 | 
				
			||||||
 | 
					  --color-sidebar-foreground: var(--sidebar-foreground);
 | 
				
			||||||
 | 
					  --color-sidebar-primary: var(--sidebar-primary);
 | 
				
			||||||
 | 
					  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
 | 
				
			||||||
 | 
					  --color-sidebar-accent: var(--sidebar-accent);
 | 
				
			||||||
 | 
					  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
 | 
				
			||||||
 | 
					  --color-sidebar-border: var(--sidebar-border);
 | 
				
			||||||
 | 
					  --color-sidebar-ring: var(--sidebar-ring);
 | 
				
			||||||
 | 
					  --color-surface: var(--surface);
 | 
				
			||||||
 | 
					  --color-surface-foreground: var(--surface-foreground);
 | 
				
			||||||
 | 
					  --color-code: var(--code);
 | 
				
			||||||
 | 
					  --color-code-foreground: var(--code-foreground);
 | 
				
			||||||
 | 
					  --color-code-highlight: var(--code-highlight);
 | 
				
			||||||
 | 
					  --color-code-number: var(--code-number);
 | 
				
			||||||
 | 
					  --color-selection: var(--selection);
 | 
				
			||||||
 | 
					  --color-selection-foreground: var(--selection-foreground);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* Default theme - Neutral gray */
 | 
				
			||||||
 | 
					:root {
 | 
				
			||||||
 | 
					  --radius: 0.65rem;
 | 
				
			||||||
 | 
					  --background: oklch(1 0 0);
 | 
				
			||||||
 | 
					  --foreground: oklch(0.145 0 0);
 | 
				
			||||||
 | 
					  --card: oklch(1 0 0);
 | 
				
			||||||
 | 
					  --card-foreground: oklch(0.145 0 0);
 | 
				
			||||||
 | 
					  --popover: oklch(1 0 0);
 | 
				
			||||||
 | 
					  --popover-foreground: oklch(0.145 0 0);
 | 
				
			||||||
 | 
					  --primary: oklch(0.205 0 0);
 | 
				
			||||||
 | 
					  --primary-foreground: oklch(0.985 0 0);
 | 
				
			||||||
 | 
					  --secondary: oklch(0.97 0 0);
 | 
				
			||||||
 | 
					  --secondary-foreground: oklch(0.205 0 0);
 | 
				
			||||||
 | 
					  --muted: oklch(0.97 0 0);
 | 
				
			||||||
 | 
					  --muted-foreground: oklch(0.556 0 0);
 | 
				
			||||||
 | 
					  --accent: oklch(0.97 0 0);
 | 
				
			||||||
 | 
					  --accent-foreground: oklch(0.205 0 0);
 | 
				
			||||||
 | 
					  --destructive: oklch(0.577 0.245 27.325);
 | 
				
			||||||
 | 
					  --border: oklch(0.922 0 0);
 | 
				
			||||||
 | 
					  --input: oklch(0.922 0 0);
 | 
				
			||||||
 | 
					  --ring: oklch(0.708 0 0);
 | 
				
			||||||
 | 
					  --chart-1: oklch(0.646 0.222 41.116);
 | 
				
			||||||
 | 
					  --chart-2: oklch(0.6 0.118 184.704);
 | 
				
			||||||
 | 
					  --chart-3: oklch(0.398 0.07 227.392);
 | 
				
			||||||
 | 
					  --chart-4: oklch(0.828 0.189 84.429);
 | 
				
			||||||
 | 
					  --chart-5: oklch(0.769 0.188 70.08);
 | 
				
			||||||
 | 
					  --radius: 0.625rem;
 | 
				
			||||||
 | 
					  --sidebar: oklch(0.985 0 0);
 | 
				
			||||||
 | 
					  --sidebar-foreground: oklch(0.145 0 0);
 | 
				
			||||||
 | 
					  --sidebar-primary: oklch(0.205 0 0);
 | 
				
			||||||
 | 
					  --sidebar-primary-foreground: oklch(0.985 0 0);
 | 
				
			||||||
 | 
					  --sidebar-accent: oklch(0.97 0 0);
 | 
				
			||||||
 | 
					  --sidebar-accent-foreground: oklch(0.205 0 0);
 | 
				
			||||||
 | 
					  --sidebar-border: oklch(0.922 0 0);
 | 
				
			||||||
 | 
					  --sidebar-ring: oklch(0.708 0 0);
 | 
				
			||||||
 | 
					  --selection: oklch(0.145 0 0);
 | 
				
			||||||
 | 
					  --selection-foreground: oklch(1 0 0);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.dark {
 | 
				
			||||||
 | 
					  --background: oklch(0.145 0 0);
 | 
				
			||||||
 | 
					  --foreground: oklch(0.985 0 0);
 | 
				
			||||||
 | 
					  --card: oklch(0.205 0 0);
 | 
				
			||||||
 | 
					  --card-foreground: oklch(0.985 0 0);
 | 
				
			||||||
 | 
					  --popover: oklch(0.205 0 0);
 | 
				
			||||||
 | 
					  --popover-foreground: oklch(0.985 0 0);
 | 
				
			||||||
 | 
					  --primary: oklch(0.922 0 0);
 | 
				
			||||||
 | 
					  --primary-foreground: oklch(0.205 0 0);
 | 
				
			||||||
 | 
					  --secondary: oklch(0.269 0 0);
 | 
				
			||||||
 | 
					  --secondary-foreground: oklch(0.985 0 0);
 | 
				
			||||||
 | 
					  --muted: oklch(0.269 0 0);
 | 
				
			||||||
 | 
					  --muted-foreground: oklch(0.708 0 0);
 | 
				
			||||||
 | 
					  --accent: oklch(0.269 0 0);
 | 
				
			||||||
 | 
					  --accent-foreground: oklch(0.985 0 0);
 | 
				
			||||||
 | 
					  --destructive: oklch(0.704 0.191 22.216);
 | 
				
			||||||
 | 
					  --border: oklch(1 0 0 / 10%);
 | 
				
			||||||
 | 
					  --input: oklch(1 0 0 / 15%);
 | 
				
			||||||
 | 
					  --ring: oklch(0.556 0 0);
 | 
				
			||||||
 | 
					  --chart-1: oklch(0.488 0.243 264.376);
 | 
				
			||||||
 | 
					  --chart-2: oklch(0.696 0.17 162.48);
 | 
				
			||||||
 | 
					  --chart-3: oklch(0.769 0.188 70.08);
 | 
				
			||||||
 | 
					  --chart-4: oklch(0.627 0.265 303.9);
 | 
				
			||||||
 | 
					  --chart-5: oklch(0.645 0.246 16.439);
 | 
				
			||||||
 | 
					  --sidebar: oklch(0.205 0 0);
 | 
				
			||||||
 | 
					  --sidebar-foreground: oklch(0.985 0 0);
 | 
				
			||||||
 | 
					  --sidebar-primary: oklch(0.488 0.243 264.376);
 | 
				
			||||||
 | 
					  --sidebar-primary-foreground: oklch(0.985 0 0);
 | 
				
			||||||
 | 
					  --sidebar-accent: oklch(0.269 0 0);
 | 
				
			||||||
 | 
					  --sidebar-accent-foreground: oklch(0.985 0 0);
 | 
				
			||||||
 | 
					  --sidebar-border: oklch(1 0 0 / 10%);
 | 
				
			||||||
 | 
					  --sidebar-ring: oklch(0.556 0 0);
 | 
				
			||||||
 | 
					  --selection: oklch(0.922 0 0);
 | 
				
			||||||
 | 
					  --selection-foreground: oklch(0.205 0 0);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@layer base {
 | 
				
			||||||
 | 
					  * {
 | 
				
			||||||
 | 
					    @apply border-border;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  ::selection {
 | 
				
			||||||
 | 
					    @apply bg-selection text-selection-foreground;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  body {
 | 
				
			||||||
 | 
					    @apply bg-background text-foreground;
 | 
				
			||||||
 | 
					    font-feature-settings:
 | 
				
			||||||
 | 
					      "rlig" 1,
 | 
				
			||||||
 | 
					      "calt" 1;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										1393
									
								
								assets/css/output.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1393
									
								
								assets/css/output.css
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										1
									
								
								assets/js/label.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/js/label.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					(()=>{(function(){"use strict";function a(t){let s=t.getAttribute("for"),d=s?document.getElementById(s):null,r=t.getAttribute("data-tui-label-disabled-style");if(!d||!r)return;let e=r.split(" ").filter(Boolean);d.disabled?t.classList.add(...e):t.classList.remove(...e)}document.addEventListener("DOMContentLoaded",()=>{let t=new Set;function s(){document.querySelectorAll("label[for][data-tui-label-disabled-style]").forEach(r=>{a(r);let e=r.getAttribute("for");e&&t.add(e)})}s(),new MutationObserver(r=>{r.forEach(e=>{e.type==="attributes"&&e.attributeName==="disabled"&&e.target.id&&t.has(e.target.id)&&document.querySelectorAll(`label[for="${e.target.id}"][data-tui-label-disabled-style]`).forEach(a)})}).observe(document.body,{attributes:!0,attributeFilter:["disabled"],subtree:!0}),new MutationObserver(()=>{s()}).observe(document.body,{childList:!0,subtree:!0})})})();})();
 | 
				
			||||||
							
								
								
									
										1
									
								
								assets/js/toast.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								assets/js/toast.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					(()=>{(function(){"use strict";let n=new Map;function o(t){let i=parseInt(t.dataset.tuiToastDuration||"3000"),e=t.querySelector(".toast-progress"),a={timer:null,startTime:Date.now(),remaining:i,paused:!1};n.set(t,a),e&&i>0&&(e.style.transitionDuration=i+"ms",requestAnimationFrame(()=>{e.style.transform="scaleX(0)"})),i>0&&(a.timer=setTimeout(()=>r(t),i)),t.addEventListener("mouseenter",()=>{let s=n.get(t);if(!(!s||s.paused)&&(clearTimeout(s.timer),s.remaining=s.remaining-(Date.now()-s.startTime),s.paused=!0,e)){let m=getComputedStyle(e);e.style.transitionDuration="0ms",e.style.transform=m.transform}}),t.addEventListener("mouseleave",()=>{let s=n.get(t);!s||!s.paused||s.remaining<=0||(s.startTime=Date.now(),s.paused=!1,s.timer=setTimeout(()=>r(t),s.remaining),e&&(e.style.transitionDuration=s.remaining+"ms",e.style.transform="scaleX(0)"))})}function r(t){n.delete(t),t.style.transition="opacity 300ms, transform 300ms",t.style.opacity="0",t.style.transform="translateY(1rem)",setTimeout(()=>t.remove(),300)}document.addEventListener("click",t=>{let i=t.target.closest("[data-tui-toast-dismiss]");if(i){let e=i.closest("[data-tui-toast]");e&&r(e)}}),new MutationObserver(t=>{t.forEach(i=>{i.addedNodes.forEach(e=>{e.nodeType===1&&e.matches?.("[data-tui-toast]")&&o(e)})})}).observe(document.body,{childList:!0,subtree:!0})})();})();
 | 
				
			||||||
							
								
								
									
										151
									
								
								components/button/button.templ
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								components/button/button.templ
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,151 @@
 | 
				
			|||||||
 | 
					// templui component button - version: v0.93.0 installed by templui v0.93.0
 | 
				
			||||||
 | 
					package button
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"git.hafen.run/lukas/timeshare/utils"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Variant string
 | 
				
			||||||
 | 
					type Size string
 | 
				
			||||||
 | 
					type Type string
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						VariantDefault     Variant = "default"
 | 
				
			||||||
 | 
						VariantDestructive Variant = "destructive"
 | 
				
			||||||
 | 
						VariantOutline     Variant = "outline"
 | 
				
			||||||
 | 
						VariantSecondary   Variant = "secondary"
 | 
				
			||||||
 | 
						VariantGhost       Variant = "ghost"
 | 
				
			||||||
 | 
						VariantLink        Variant = "link"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						TypeButton Type = "button"
 | 
				
			||||||
 | 
						TypeReset  Type = "reset"
 | 
				
			||||||
 | 
						TypeSubmit Type = "submit"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						SizeDefault Size = "default"
 | 
				
			||||||
 | 
						SizeSm      Size = "sm"
 | 
				
			||||||
 | 
						SizeLg      Size = "lg"
 | 
				
			||||||
 | 
						SizeIcon    Size = "icon"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Props struct {
 | 
				
			||||||
 | 
						ID         string
 | 
				
			||||||
 | 
						Class      string
 | 
				
			||||||
 | 
						Attributes templ.Attributes
 | 
				
			||||||
 | 
						Variant    Variant
 | 
				
			||||||
 | 
						Size       Size
 | 
				
			||||||
 | 
						FullWidth  bool
 | 
				
			||||||
 | 
						Href       string
 | 
				
			||||||
 | 
						Target     string
 | 
				
			||||||
 | 
						Disabled   bool
 | 
				
			||||||
 | 
						Type       Type
 | 
				
			||||||
 | 
						Form       string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					templ Button(props ...Props) {
 | 
				
			||||||
 | 
						{{ var p Props }}
 | 
				
			||||||
 | 
						if len(props) > 0 {
 | 
				
			||||||
 | 
							{{ p = props[0] }}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if p.Type == "" {
 | 
				
			||||||
 | 
							{{ p.Type = TypeButton }}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if p.Href != "" && !p.Disabled {
 | 
				
			||||||
 | 
							<a
 | 
				
			||||||
 | 
								if p.ID != "" {
 | 
				
			||||||
 | 
									id={ p.ID }
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								href={ templ.SafeURL(p.Href) }
 | 
				
			||||||
 | 
								if p.Target != "" {
 | 
				
			||||||
 | 
									target={ p.Target }
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								class={
 | 
				
			||||||
 | 
									utils.TwMerge(
 | 
				
			||||||
 | 
										"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all",
 | 
				
			||||||
 | 
										"disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
 | 
				
			||||||
 | 
										"outline-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",
 | 
				
			||||||
 | 
										"cursor-pointer",
 | 
				
			||||||
 | 
										p.variantClasses(),
 | 
				
			||||||
 | 
										p.sizeClasses(),
 | 
				
			||||||
 | 
										p.modifierClasses(),
 | 
				
			||||||
 | 
										p.Class,
 | 
				
			||||||
 | 
									),
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								{ p.Attributes... }
 | 
				
			||||||
 | 
							>
 | 
				
			||||||
 | 
								{ children... }
 | 
				
			||||||
 | 
							</a>
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
 | 
							<button
 | 
				
			||||||
 | 
								if p.ID != "" {
 | 
				
			||||||
 | 
									id={ p.ID }
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								class={
 | 
				
			||||||
 | 
									utils.TwMerge(
 | 
				
			||||||
 | 
										"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all",
 | 
				
			||||||
 | 
										"disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
 | 
				
			||||||
 | 
										"outline-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",
 | 
				
			||||||
 | 
										"cursor-pointer",
 | 
				
			||||||
 | 
										p.variantClasses(),
 | 
				
			||||||
 | 
										p.sizeClasses(),
 | 
				
			||||||
 | 
										p.modifierClasses(),
 | 
				
			||||||
 | 
										p.Class,
 | 
				
			||||||
 | 
									),
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								if p.Type != "" {
 | 
				
			||||||
 | 
									type={ string(p.Type) }
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								if p.Form != "" {
 | 
				
			||||||
 | 
									form={ p.Form }
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								disabled?={ p.Disabled }
 | 
				
			||||||
 | 
								{ p.Attributes... }
 | 
				
			||||||
 | 
							>
 | 
				
			||||||
 | 
								{ children... }
 | 
				
			||||||
 | 
							</button>
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (b Props) variantClasses() string {
 | 
				
			||||||
 | 
						switch b.Variant {
 | 
				
			||||||
 | 
						case VariantDestructive:
 | 
				
			||||||
 | 
							return "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60"
 | 
				
			||||||
 | 
						case VariantOutline:
 | 
				
			||||||
 | 
							return "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50"
 | 
				
			||||||
 | 
						case VariantSecondary:
 | 
				
			||||||
 | 
							return "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80"
 | 
				
			||||||
 | 
						case VariantGhost:
 | 
				
			||||||
 | 
							return "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
 | 
				
			||||||
 | 
						case VariantLink:
 | 
				
			||||||
 | 
							return "text-primary underline-offset-4 hover:underline"
 | 
				
			||||||
 | 
						default:
 | 
				
			||||||
 | 
							return "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90"
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (b Props) sizeClasses() string {
 | 
				
			||||||
 | 
						switch b.Size {
 | 
				
			||||||
 | 
						case SizeSm:
 | 
				
			||||||
 | 
							return "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5"
 | 
				
			||||||
 | 
						case SizeLg:
 | 
				
			||||||
 | 
							return "h-10 rounded-md px-6 has-[>svg]:px-4"
 | 
				
			||||||
 | 
						case SizeIcon:
 | 
				
			||||||
 | 
							return "size-9"
 | 
				
			||||||
 | 
						default: // SizeDefault
 | 
				
			||||||
 | 
							return "h-9 px-4 py-2 has-[>svg]:px-3"
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (b Props) modifierClasses() string {
 | 
				
			||||||
 | 
						classes := []string{}
 | 
				
			||||||
 | 
						if b.FullWidth {
 | 
				
			||||||
 | 
							classes = append(classes, "w-full")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return strings.Join(classes, " ")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										117
									
								
								components/icon/icon.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								components/icon/icon.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,117 @@
 | 
				
			|||||||
 | 
					// templui component icon - version: v0.94.0 installed by templui v0.94.0
 | 
				
			||||||
 | 
					package icon
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"context"
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"io"
 | 
				
			||||||
 | 
						"sync"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/a-h/templ"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// iconContents caches the fully generated SVG strings for icons that have been used,
 | 
				
			||||||
 | 
					// keyed by a composite key of name and props to handle different stylings.
 | 
				
			||||||
 | 
					var (
 | 
				
			||||||
 | 
						iconContents = make(map[string]string)
 | 
				
			||||||
 | 
						iconMutex    sync.RWMutex
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Props defines the properties that can be set for an icon.
 | 
				
			||||||
 | 
					type Props struct {
 | 
				
			||||||
 | 
						Size        int
 | 
				
			||||||
 | 
						Color       string
 | 
				
			||||||
 | 
						Fill        string
 | 
				
			||||||
 | 
						Stroke      string
 | 
				
			||||||
 | 
						StrokeWidth string // Stroke Width of Icon, Usage: "2.5"
 | 
				
			||||||
 | 
						Class       string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Icon returns a function that generates a templ.Component for the specified icon name.
 | 
				
			||||||
 | 
					func Icon(name string) func(...Props) templ.Component {
 | 
				
			||||||
 | 
						return func(props ...Props) templ.Component {
 | 
				
			||||||
 | 
							var p Props
 | 
				
			||||||
 | 
							if len(props) > 0 {
 | 
				
			||||||
 | 
								p = props[0]
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Create a unique key for the cache based on icon name and all relevant props.
 | 
				
			||||||
 | 
							// This ensures different stylings of the same icon are cached separately.
 | 
				
			||||||
 | 
							cacheKey := fmt.Sprintf("%s|s:%d|c:%s|f:%s|sk:%s|sw:%s|cl:%s",
 | 
				
			||||||
 | 
								name, p.Size, p.Color, p.Fill, p.Stroke, p.StrokeWidth, p.Class)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
 | 
				
			||||||
 | 
								iconMutex.RLock()
 | 
				
			||||||
 | 
								svg, cached := iconContents[cacheKey]
 | 
				
			||||||
 | 
								iconMutex.RUnlock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if cached {
 | 
				
			||||||
 | 
									_, err = w.Write([]byte(svg))
 | 
				
			||||||
 | 
									return err
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								// Not cached, generate it
 | 
				
			||||||
 | 
								// The actual generation now happens once and is cached.
 | 
				
			||||||
 | 
								generatedSvg, err := generateSVG(name, p) // p (Props) is passed to generateSVG
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									// Provide more context in the error message
 | 
				
			||||||
 | 
									return fmt.Errorf("failed to generate svg for icon '%s' with props %+v: %w", name, p, err)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								iconMutex.Lock()
 | 
				
			||||||
 | 
								iconContents[cacheKey] = generatedSvg
 | 
				
			||||||
 | 
								iconMutex.Unlock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								_, err = w.Write([]byte(generatedSvg))
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// generateSVG creates an SVG string for the specified icon with the given properties.
 | 
				
			||||||
 | 
					// This function is called when an icon-prop combination is not yet in the cache.
 | 
				
			||||||
 | 
					func generateSVG(name string, props Props) (string, error) {
 | 
				
			||||||
 | 
						// Get the raw, inner SVG content for the icon name from our internal data map.
 | 
				
			||||||
 | 
						content, err := getIconContent(name) // This now reads from internalSvgData
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return "", err // Error from getIconContent already includes icon name
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						size := props.Size
 | 
				
			||||||
 | 
						if size <= 0 {
 | 
				
			||||||
 | 
							size = 24 // Default size
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						fill := props.Fill
 | 
				
			||||||
 | 
						if fill == "" {
 | 
				
			||||||
 | 
							fill = "none" // Default fill
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						stroke := props.Stroke
 | 
				
			||||||
 | 
						if stroke == "" {
 | 
				
			||||||
 | 
							stroke = props.Color // Fallback to Color if Stroke is not set
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if stroke == "" {
 | 
				
			||||||
 | 
							stroke = "currentColor" // Default stroke color
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						strokeWidth := props.StrokeWidth
 | 
				
			||||||
 | 
						if strokeWidth == "" {
 | 
				
			||||||
 | 
							strokeWidth = "2" // Default stroke width
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Construct the final SVG string.
 | 
				
			||||||
 | 
						// The data-lucide attribute helps identify these as Lucide icons if needed.
 | 
				
			||||||
 | 
						return fmt.Sprintf("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%d\" height=\"%d\" viewBox=\"0 0 24 24\" fill=\"%s\" stroke=\"%s\" stroke-width=\"%s\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"%s\" data-lucide=\"icon\">%s</svg>",
 | 
				
			||||||
 | 
							size, size, fill, stroke, strokeWidth, props.Class, content), nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// getIconContent retrieves the raw inner SVG content for a given icon name.
 | 
				
			||||||
 | 
					// It reads from the pre-generated internalSvgData map from icon_data.go.
 | 
				
			||||||
 | 
					func getIconContent(name string) (string, error) {
 | 
				
			||||||
 | 
						content, exists := internalSvgData[name]
 | 
				
			||||||
 | 
						if !exists {
 | 
				
			||||||
 | 
							return "", fmt.Errorf("icon '%s' not found in internalSvgData map", name)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return content, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										6289
									
								
								components/icon/icondata.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6289
									
								
								components/icon/icondata.go
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										1595
									
								
								components/icon/icondefs.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1595
									
								
								components/icon/icondefs.go
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										42
									
								
								components/label/label.templ
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								components/label/label.templ
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					// templui component label - version: v0.93.0 installed by templui v0.93.0
 | 
				
			||||||
 | 
					package label
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import "git.hafen.run/lukas/timeshare/utils"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Props struct {
 | 
				
			||||||
 | 
						ID         string
 | 
				
			||||||
 | 
						Class      string
 | 
				
			||||||
 | 
						Attributes templ.Attributes
 | 
				
			||||||
 | 
						For        string
 | 
				
			||||||
 | 
						Error      string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					templ Label(props ...Props) {
 | 
				
			||||||
 | 
						{{ var p Props }}
 | 
				
			||||||
 | 
						if len(props) > 0 {
 | 
				
			||||||
 | 
							{{ p = props[0] }}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						<label
 | 
				
			||||||
 | 
							if p.ID != "" {
 | 
				
			||||||
 | 
								id={ p.ID }
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if p.For != "" {
 | 
				
			||||||
 | 
								for={ p.For }
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							class={
 | 
				
			||||||
 | 
								utils.TwMerge(
 | 
				
			||||||
 | 
									"text-sm font-medium leading-none inline-block",
 | 
				
			||||||
 | 
									utils.If(len(p.Error) > 0, "text-destructive"),
 | 
				
			||||||
 | 
									p.Class,
 | 
				
			||||||
 | 
								),
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							data-tui-label-disabled-style="opacity-50 cursor-not-allowed"
 | 
				
			||||||
 | 
							{ p.Attributes... }
 | 
				
			||||||
 | 
						>
 | 
				
			||||||
 | 
							{ children... }
 | 
				
			||||||
 | 
						</label>
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					templ Script() {
 | 
				
			||||||
 | 
						<script defer nonce={ templ.GetNonce(ctx) } src="/assets/js/label.min.js"></script>
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										58
									
								
								components/radio/radio.templ
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								components/radio/radio.templ
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,58 @@
 | 
				
			|||||||
 | 
					// templui component radio - version: v0.93.0 installed by templui v0.93.0
 | 
				
			||||||
 | 
					package radio
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import "git.hafen.run/lukas/timeshare/utils"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Props struct {
 | 
				
			||||||
 | 
						ID         string
 | 
				
			||||||
 | 
						Class      string
 | 
				
			||||||
 | 
						Attributes templ.Attributes
 | 
				
			||||||
 | 
						Name       string
 | 
				
			||||||
 | 
						Value      string
 | 
				
			||||||
 | 
						Form       string
 | 
				
			||||||
 | 
						Disabled   bool
 | 
				
			||||||
 | 
						Required   bool
 | 
				
			||||||
 | 
						Checked    bool
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					templ Radio(props ...Props) {
 | 
				
			||||||
 | 
						{{ var p Props }}
 | 
				
			||||||
 | 
						if len(props) > 0 {
 | 
				
			||||||
 | 
							{{ p = props[0] }}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						<input
 | 
				
			||||||
 | 
							type="radio"
 | 
				
			||||||
 | 
							if p.ID != "" {
 | 
				
			||||||
 | 
								id={ p.ID }
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if p.Name != "" {
 | 
				
			||||||
 | 
								name={ p.Name }
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if p.Value != "" {
 | 
				
			||||||
 | 
								value={ p.Value }
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if p.Form != "" {
 | 
				
			||||||
 | 
								form={ p.Form }
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							checked?={ p.Checked }
 | 
				
			||||||
 | 
							disabled?={ p.Disabled }
 | 
				
			||||||
 | 
							required?={ p.Required }
 | 
				
			||||||
 | 
							class={
 | 
				
			||||||
 | 
								utils.TwMerge(
 | 
				
			||||||
 | 
									"relative h-4 w-4",
 | 
				
			||||||
 | 
									"before:absolute before:left-1/2 before:top-1/2",
 | 
				
			||||||
 | 
									"before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2",
 | 
				
			||||||
 | 
									"appearance-none rounded-full",
 | 
				
			||||||
 | 
									"border-2 border-primary",
 | 
				
			||||||
 | 
									"before:content[''] before:rounded-full before:bg-background",
 | 
				
			||||||
 | 
									"checked:border-primary checked:bg-primary",
 | 
				
			||||||
 | 
									"checked:before:visible",
 | 
				
			||||||
 | 
									"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
 | 
				
			||||||
 | 
									"focus-visible:ring-offset-2 focus-visible:ring-offset-background",
 | 
				
			||||||
 | 
									"disabled:cursor-not-allowed",
 | 
				
			||||||
 | 
									p.Class,
 | 
				
			||||||
 | 
								),
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							{ p.Attributes... }
 | 
				
			||||||
 | 
						/>
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										153
									
								
								components/toast/toast.templ
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								components/toast/toast.templ
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,153 @@
 | 
				
			|||||||
 | 
					// templui component toast - version: v0.94.0 installed by templui v0.94.0
 | 
				
			||||||
 | 
					package toast
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"git.hafen.run/lukas/timeshare/components/button"
 | 
				
			||||||
 | 
						"git.hafen.run/lukas/timeshare/components/icon"
 | 
				
			||||||
 | 
						"git.hafen.run/lukas/timeshare/utils"
 | 
				
			||||||
 | 
						"strconv"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Variant string
 | 
				
			||||||
 | 
					type Position string
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						VariantDefault Variant = "default"
 | 
				
			||||||
 | 
						VariantSuccess Variant = "success"
 | 
				
			||||||
 | 
						VariantError   Variant = "error"
 | 
				
			||||||
 | 
						VariantWarning Variant = "warning"
 | 
				
			||||||
 | 
						VariantInfo    Variant = "info"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						PositionTopRight     Position = "top-right"
 | 
				
			||||||
 | 
						PositionTopLeft      Position = "top-left"
 | 
				
			||||||
 | 
						PositionTopCenter    Position = "top-center"
 | 
				
			||||||
 | 
						PositionBottomRight  Position = "bottom-right"
 | 
				
			||||||
 | 
						PositionBottomLeft   Position = "bottom-left"
 | 
				
			||||||
 | 
						PositionBottomCenter Position = "bottom-center"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Props struct {
 | 
				
			||||||
 | 
						ID            string
 | 
				
			||||||
 | 
						Class         string
 | 
				
			||||||
 | 
						Attributes    templ.Attributes
 | 
				
			||||||
 | 
						Title         string
 | 
				
			||||||
 | 
						Description   string
 | 
				
			||||||
 | 
						Variant       Variant
 | 
				
			||||||
 | 
						Position      Position
 | 
				
			||||||
 | 
						Duration      int
 | 
				
			||||||
 | 
						Dismissible   bool
 | 
				
			||||||
 | 
						ShowIndicator bool
 | 
				
			||||||
 | 
						Icon          bool
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					templ Toast(props ...Props) {
 | 
				
			||||||
 | 
						{{ var p Props }}
 | 
				
			||||||
 | 
						if len(props) > 0 {
 | 
				
			||||||
 | 
							{{ p = props[0] }}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if p.ID == "" {
 | 
				
			||||||
 | 
							{{ p.ID = utils.RandomID() }}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						// Set defaults
 | 
				
			||||||
 | 
						if p.Variant == "" {
 | 
				
			||||||
 | 
							{{ p.Variant = VariantDefault }}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if p.Position == "" {
 | 
				
			||||||
 | 
							{{ p.Position = PositionBottomRight }}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if p.Duration == 0 {
 | 
				
			||||||
 | 
							{{ p.Duration = 3000 }}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						<div
 | 
				
			||||||
 | 
							id={ p.ID }
 | 
				
			||||||
 | 
							data-tui-toast
 | 
				
			||||||
 | 
							data-tui-toast-duration={ strconv.Itoa(p.Duration) }
 | 
				
			||||||
 | 
							data-position={ string(p.Position) }
 | 
				
			||||||
 | 
							data-variant={ string(p.Variant) }
 | 
				
			||||||
 | 
							class={ utils.TwMerge(
 | 
				
			||||||
 | 
								// Base styles
 | 
				
			||||||
 | 
								"z-50 fixed pointer-events-auto p-4 w-full md:max-w-[420px]",
 | 
				
			||||||
 | 
								// Animation
 | 
				
			||||||
 | 
								"animate-in fade-in slide-in-from-bottom-4 duration-300",
 | 
				
			||||||
 | 
								// Position-based styles using data attributes
 | 
				
			||||||
 | 
								"data-[position=top-right]:top-0 data-[position=top-right]:right-0",
 | 
				
			||||||
 | 
								"data-[position=top-left]:top-0 data-[position=top-left]:left-0",
 | 
				
			||||||
 | 
								"data-[position=top-center]:top-0 data-[position=top-center]:left-1/2 data-[position=top-center]:-translate-x-1/2",
 | 
				
			||||||
 | 
								"data-[position=bottom-right]:bottom-0 data-[position=bottom-right]:right-0",
 | 
				
			||||||
 | 
								"data-[position=bottom-left]:bottom-0 data-[position=bottom-left]:left-0",
 | 
				
			||||||
 | 
								"data-[position=bottom-center]:bottom-0 data-[position=bottom-center]:left-1/2 data-[position=bottom-center]:-translate-x-1/2",
 | 
				
			||||||
 | 
								// Slide direction based on position
 | 
				
			||||||
 | 
								"data-[position*=top]:slide-in-from-top-4",
 | 
				
			||||||
 | 
								"data-[position*=bottom]:slide-in-from-bottom-4",
 | 
				
			||||||
 | 
								p.Class,
 | 
				
			||||||
 | 
							) }
 | 
				
			||||||
 | 
							{ p.Attributes... }
 | 
				
			||||||
 | 
						>
 | 
				
			||||||
 | 
							<div class="w-full bg-popover text-popover-foreground rounded-lg shadow-xs border pt-5 pb-4 px-4 flex items-center justify-center relative overflow-hidden group">
 | 
				
			||||||
 | 
								// Progress indicator
 | 
				
			||||||
 | 
								if p.ShowIndicator && p.Duration > 0 {
 | 
				
			||||||
 | 
									<div class="absolute top-0 left-0 right-0 h-1 overflow-hidden">
 | 
				
			||||||
 | 
										<div
 | 
				
			||||||
 | 
											class={ utils.TwMerge(
 | 
				
			||||||
 | 
												"toast-progress h-full origin-left transition-transform ease-linear",
 | 
				
			||||||
 | 
												// Variant colors
 | 
				
			||||||
 | 
												"data-[variant=default]:bg-gray-500",
 | 
				
			||||||
 | 
												"data-[variant=success]:bg-green-500",
 | 
				
			||||||
 | 
												"data-[variant=error]:bg-red-500",
 | 
				
			||||||
 | 
												"data-[variant=warning]:bg-yellow-500",
 | 
				
			||||||
 | 
												"data-[variant=info]:bg-blue-500",
 | 
				
			||||||
 | 
											) }
 | 
				
			||||||
 | 
											data-variant={ string(p.Variant) }
 | 
				
			||||||
 | 
											data-duration={ strconv.Itoa(p.Duration) }
 | 
				
			||||||
 | 
										></div>
 | 
				
			||||||
 | 
									</div>
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								// Icon
 | 
				
			||||||
 | 
								if p.Icon {
 | 
				
			||||||
 | 
									switch p.Variant {
 | 
				
			||||||
 | 
										case VariantSuccess:
 | 
				
			||||||
 | 
											@icon.CircleCheck(icon.Props{Size: 22, Class: "text-green-500 mr-3 flex-shrink-0"})
 | 
				
			||||||
 | 
										case VariantError:
 | 
				
			||||||
 | 
											@icon.CircleX(icon.Props{Size: 22, Class: "text-red-500 mr-3 flex-shrink-0"})
 | 
				
			||||||
 | 
										case VariantWarning:
 | 
				
			||||||
 | 
											@icon.TriangleAlert(icon.Props{Size: 22, Class: "text-yellow-500 mr-3 flex-shrink-0"})
 | 
				
			||||||
 | 
										case VariantInfo:
 | 
				
			||||||
 | 
											@icon.Info(icon.Props{Size: 22, Class: "text-blue-500 mr-3 flex-shrink-0"})
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								// Content
 | 
				
			||||||
 | 
								<span class="flex-1 min-w-0">
 | 
				
			||||||
 | 
									if p.Title != "" {
 | 
				
			||||||
 | 
										<p class="text-sm font-semibold truncate">{ p.Title }</p>
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									if p.Description != "" {
 | 
				
			||||||
 | 
										<p class="text-sm opacity-90 mt-1">@templ.Raw(p.Description)
 | 
				
			||||||
 | 
					</p>
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								</span>
 | 
				
			||||||
 | 
								// Dismiss button
 | 
				
			||||||
 | 
								if p.Dismissible {
 | 
				
			||||||
 | 
									@button.Button(button.Props{
 | 
				
			||||||
 | 
										Size:    button.SizeIcon,
 | 
				
			||||||
 | 
										Variant: button.VariantGhost,
 | 
				
			||||||
 | 
										Attributes: templ.Attributes{
 | 
				
			||||||
 | 
											"aria-label":             "Close",
 | 
				
			||||||
 | 
											"data-tui-toast-dismiss": "",
 | 
				
			||||||
 | 
											"type":                   "button",
 | 
				
			||||||
 | 
										},
 | 
				
			||||||
 | 
									}) {
 | 
				
			||||||
 | 
										@icon.X(icon.Props{
 | 
				
			||||||
 | 
											Size:  18,
 | 
				
			||||||
 | 
											Class: "opacity-75 hover:opacity-100",
 | 
				
			||||||
 | 
										})
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
						</div>
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					templ Script() {
 | 
				
			||||||
 | 
						<script defer nonce={ templ.GetNonce(ctx) } src="/assets/js/toast.min.js"></script>
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										34
									
								
								dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								dockerfile
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,34 @@
 | 
				
			|||||||
 | 
					FROM --platform=$BUILDPLATFORM golang:alpine AS build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN apk add git
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					WORKDIR /src/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY go.* /src/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN go mod download -x
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY . /src
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#Compiler Settings
 | 
				
			||||||
 | 
					ENV CGO_ENABLED=0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# for full parings check out https://go.dev/doc/install/source#environment
 | 
				
			||||||
 | 
					ENV GOOS=linux
 | 
				
			||||||
 | 
					# this will be the target cpu arch
 | 
				
			||||||
 | 
					# Can be amd64 arm64 386 ppc64
 | 
				
			||||||
 | 
					ENV GOARCH=amd64
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN go build -o /out/app .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# if you need certificates use: alpine
 | 
				
			||||||
 | 
					# otherwise just use: scratch
 | 
				
			||||||
 | 
					FROM alpine AS run
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY --from=build /out/app /
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# if needed
 | 
				
			||||||
 | 
					EXPOSE 8080
 | 
				
			||||||
 | 
					ENV VALKEY_ADDR redis:6379
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ENTRYPOINT [ "/app" ]
 | 
				
			||||||
							
								
								
									
										12
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					module git.hafen.run/lukas/timeshare
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					go 1.25.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require (
 | 
				
			||||||
 | 
						github.com/Oudwins/tailwind-merge-go v0.2.1 // indirect
 | 
				
			||||||
 | 
						github.com/a-h/templ v0.3.943 // indirect
 | 
				
			||||||
 | 
						github.com/gabriel-vasile/mimetype v1.4.10 // indirect
 | 
				
			||||||
 | 
						github.com/google/uuid v1.6.0 // indirect
 | 
				
			||||||
 | 
						github.com/valkey-io/valkey-go v1.0.64 // indirect
 | 
				
			||||||
 | 
						golang.org/x/sys v0.34.0 // indirect
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
							
								
								
									
										12
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					github.com/Oudwins/tailwind-merge-go v0.2.1 h1:jxRaEqGtwwwF48UuFIQ8g8XT7YSualNuGzCvQ89nPFE=
 | 
				
			||||||
 | 
					github.com/Oudwins/tailwind-merge-go v0.2.1/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U=
 | 
				
			||||||
 | 
					github.com/a-h/templ v0.3.943 h1:o+mT/4yqhZ33F3ootBiHwaY4HM5EVaOJfIshvd5UNTY=
 | 
				
			||||||
 | 
					github.com/a-h/templ v0.3.943/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
 | 
				
			||||||
 | 
					github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
 | 
				
			||||||
 | 
					github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
 | 
				
			||||||
 | 
					github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 | 
				
			||||||
 | 
					github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 | 
				
			||||||
 | 
					github.com/valkey-io/valkey-go v1.0.64 h1:3u4+b6D6zs9JQs254TLy4LqitCMHHr9XorP9GGk7XY4=
 | 
				
			||||||
 | 
					github.com/valkey-io/valkey-go v1.0.64/go.mod h1:bHmwjIEOrGq/ubOJfh5uMRs7Xj6mV3mQ/ZXUbmqpjqY=
 | 
				
			||||||
 | 
					golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
 | 
				
			||||||
 | 
					golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 | 
				
			||||||
							
								
								
									
										20
									
								
								guard.dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								guard.dockerfile
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
				
			|||||||
 | 
					FROM --platform=$BUILDPLATFORM golang:alpine AS build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# for full parings check out https://go.dev/doc/install/source#environment
 | 
				
			||||||
 | 
					ENV GOOS=linux
 | 
				
			||||||
 | 
					# this will be the target cpu arch
 | 
				
			||||||
 | 
					# Can be amd64 arm64 386 ppc64
 | 
				
			||||||
 | 
					ENV GOARCH=amd64
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN apk add git
 | 
				
			||||||
 | 
					WORKDIR /src/
 | 
				
			||||||
 | 
					RUN git clone https://git.hafen.run/lukas/oauth-guard.git
 | 
				
			||||||
 | 
					WORKDIR /src/oauth-guard
 | 
				
			||||||
 | 
					RUN go mod download -x
 | 
				
			||||||
 | 
					ENV CGO_ENABLED=0
 | 
				
			||||||
 | 
					RUN go build -o oauth-guard .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY config.toml .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					EXPOSE 3000
 | 
				
			||||||
 | 
					CMD [ "./oauth-guard" ]
 | 
				
			||||||
							
								
								
									
										170
									
								
								main.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								main.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,170 @@
 | 
				
			|||||||
 | 
					package main
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"archive/zip"
 | 
				
			||||||
 | 
						"bytes"
 | 
				
			||||||
 | 
						"embed"
 | 
				
			||||||
 | 
						"encoding/json"
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"io"
 | 
				
			||||||
 | 
						"log"
 | 
				
			||||||
 | 
						"mime/multipart"
 | 
				
			||||||
 | 
						"net/http"
 | 
				
			||||||
 | 
						"os"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"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"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//go:embed assets
 | 
				
			||||||
 | 
					var assetsFS embed.FS
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type File struct {
 | 
				
			||||||
 | 
						FileKey string
 | 
				
			||||||
 | 
						File    *multipart.FileHeader
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func main() {
 | 
				
			||||||
 | 
						valkeyAddr := os.Getenv("VALKEY_ADDR")
 | 
				
			||||||
 | 
						if valkeyAddr == "" {
 | 
				
			||||||
 | 
							valkeyAddr = "127.0.0.1:6379"
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						client, err := valkey.NewClient(valkey.ClientOption{InitAddress: []string{valkeyAddr}})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatalln("unable to connect to valkey instance: ", err.Error())
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						http.Handle("/assets/", http.FileServer(http.FS(assetsFS)))
 | 
				
			||||||
 | 
						http.Handle("GET /upload", 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"},
 | 
				
			||||||
 | 
						}, "")))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						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("/", templ.Handler(pages.Index()))
 | 
				
			||||||
 | 
						log.Println(http.ListenAndServe(":8080", nil))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										22
									
								
								makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								makefile
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,22 @@
 | 
				
			|||||||
 | 
					# Run templ generation in watch mode
 | 
				
			||||||
 | 
					templ:
 | 
				
			||||||
 | 
						templ generate --watch --proxy="http://localhost:8090" --open-browser=false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Run air for Go hot reload
 | 
				
			||||||
 | 
					server:
 | 
				
			||||||
 | 
						air \
 | 
				
			||||||
 | 
						--build.cmd "go build -o tmp/bin/main ./main.go" \
 | 
				
			||||||
 | 
						--build.bin "tmp/bin/main" \
 | 
				
			||||||
 | 
						--build.delay "100" \
 | 
				
			||||||
 | 
						--build.exclude_dir "node_modules" \
 | 
				
			||||||
 | 
						--build.include_ext "go" \
 | 
				
			||||||
 | 
						--build.stop_on_error "false" \
 | 
				
			||||||
 | 
						--misc.clean_on_exit true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Watch Tailwind CSS changes
 | 
				
			||||||
 | 
					tailwind:
 | 
				
			||||||
 | 
						tailwindcss -i ./assets/css/input.css -o ./assets/css/output.css --watch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Start development server with all watchers
 | 
				
			||||||
 | 
					dev:
 | 
				
			||||||
 | 
						make -j3 tailwind templ server
 | 
				
			||||||
							
								
								
									
										10
									
								
								pages/index.templ
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								pages/index.templ
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					package pages
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					templ Index() {
 | 
				
			||||||
 | 
						@PageSkeleton("Time Share") {
 | 
				
			||||||
 | 
							<div class="flex flex-col p-10 w-full h-screen justify-center items-center">
 | 
				
			||||||
 | 
								<h1 class="text-4xl font-semibold">Time Share</h1>
 | 
				
			||||||
 | 
								<p><a class="text-md underline" href="https://lukaswerner.com">Lukas Werner</a></p>
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										30
									
								
								pages/skeleton.templ
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								pages/skeleton.templ
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,30 @@
 | 
				
			|||||||
 | 
					package pages
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import "git.hafen.run/lukas/timeshare/components/toast"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					templ PageSkeleton(title string) {
 | 
				
			||||||
 | 
						<!DOCTYPE html>
 | 
				
			||||||
 | 
						<html>
 | 
				
			||||||
 | 
							<head>
 | 
				
			||||||
 | 
								<meta charset="UTF-8"/>
 | 
				
			||||||
 | 
								<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
 | 
				
			||||||
 | 
								<!-- Tailwind CSS (output) -->
 | 
				
			||||||
 | 
								<link href="/assets/css/output.css" rel="stylesheet"/>
 | 
				
			||||||
 | 
								<title>{ title }</title>
 | 
				
			||||||
 | 
								<link rel="apple-touch-icon" sizes="180x180" href="/assets/images/apple-touch-icon.png"/>
 | 
				
			||||||
 | 
								<link rel="icon" type="image/png" sizes="32x32" href="/assets/images/favicon-32x32.png"/>
 | 
				
			||||||
 | 
								<link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon-16x16.png"/>
 | 
				
			||||||
 | 
								<link rel="manifest" href="/assets/site.webmanifest"/>
 | 
				
			||||||
 | 
								<style>
 | 
				
			||||||
 | 
								::view-transition-old(root),
 | 
				
			||||||
 | 
								::view-transition-new(root) {
 | 
				
			||||||
 | 
								  animation-duration: 0.5s;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								</style>
 | 
				
			||||||
 | 
							</head>
 | 
				
			||||||
 | 
							<body class="p-0 m-0">
 | 
				
			||||||
 | 
								{ children... }
 | 
				
			||||||
 | 
								@toast.Script()
 | 
				
			||||||
 | 
							</body>
 | 
				
			||||||
 | 
						</html>
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										98
									
								
								pages/upload.templ
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								pages/upload.templ
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,98 @@
 | 
				
			|||||||
 | 
					package pages
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"git.hafen.run/lukas/timeshare/components/button"
 | 
				
			||||||
 | 
						"git.hafen.run/lukas/timeshare/components/label"
 | 
				
			||||||
 | 
						"git.hafen.run/lukas/timeshare/components/radio"
 | 
				
			||||||
 | 
						"git.hafen.run/lukas/timeshare/components/toast"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Expiry struct {
 | 
				
			||||||
 | 
						DurationCode string
 | 
				
			||||||
 | 
						DurationName string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					templ Upload(expirations []Expiry, uploadedLink string) {
 | 
				
			||||||
 | 
						@PageSkeleton("Upload - Time Share") {
 | 
				
			||||||
 | 
							if uploadedLink != "" {
 | 
				
			||||||
 | 
								@toast.Toast(toast.Props{
 | 
				
			||||||
 | 
									Title:       "Share Created!",
 | 
				
			||||||
 | 
									Description: fmt.Sprintf(`<a class="underline" href="%s">%s</a>`, uploadedLink, uploadedLink),
 | 
				
			||||||
 | 
									Variant:     toast.VariantSuccess,
 | 
				
			||||||
 | 
									Duration:    120000, // 2 min
 | 
				
			||||||
 | 
									Position:    toast.PositionBottomCenter,
 | 
				
			||||||
 | 
									Dismissible: true,
 | 
				
			||||||
 | 
									Icon:        true,
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							<div class="flex flex-col p-10 w-full h-screen justify-center items-center">
 | 
				
			||||||
 | 
								<form class="flex flex-col items-center gap-2" method="POST" enctype="multipart/form-data">
 | 
				
			||||||
 | 
									<h1 class="text-3xl font-medium pb-4">Time Share Upload</h1>
 | 
				
			||||||
 | 
									<input type="file" class="hidden" id="files" name="files" multiple/>
 | 
				
			||||||
 | 
									<div class="border-2 border-dashed rounded-xl border-gray-400 flex flex-col justify-center items-center p-10" id="drop_zone">
 | 
				
			||||||
 | 
										<p id="desc" class="w-[39ch] text-center">Drop files onto here or click to upload</p>
 | 
				
			||||||
 | 
									</div>
 | 
				
			||||||
 | 
									<script>
 | 
				
			||||||
 | 
										let drop_zone = document.getElementById("drop_zone");
 | 
				
			||||||
 | 
										let desc = document.getElementById("desc");
 | 
				
			||||||
 | 
										let fileInput = document.getElementById("files");
 | 
				
			||||||
 | 
										drop_zone.addEventListener("click", function (ev) {
 | 
				
			||||||
 | 
												fileInput.click();
 | 
				
			||||||
 | 
											})
 | 
				
			||||||
 | 
										drop_zone.addEventListener("drop", function (ev) {
 | 
				
			||||||
 | 
											ev.preventDefault();
 | 
				
			||||||
 | 
											drop_zone.classList.remove('dragover');
 | 
				
			||||||
 | 
											const dt = ev.dataTransfer;
 | 
				
			||||||
 | 
											if (!dt) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
											const files = Array.from(dt.files); // iterable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
											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"
 | 
				
			||||||
 | 
											}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										});
 | 
				
			||||||
 | 
										drop_zone.addEventListener("dragover", function (e) {
 | 
				
			||||||
 | 
											drop_zone.classList.add('dragover');
 | 
				
			||||||
 | 
											e.preventDefault();
 | 
				
			||||||
 | 
										});
 | 
				
			||||||
 | 
										drop_zone.addEventListener("dragleave", function (e) {
 | 
				
			||||||
 | 
											drop_zone.classList.remove('dragover');
 | 
				
			||||||
 | 
											e.preventDefault();
 | 
				
			||||||
 | 
										});
 | 
				
			||||||
 | 
									</script>
 | 
				
			||||||
 | 
									<style>.dragover {border-color: #00C950;}</style>
 | 
				
			||||||
 | 
									<div class="flex flex-col gap-3 w-full">
 | 
				
			||||||
 | 
										<p>Expiration:</p>
 | 
				
			||||||
 | 
										<div class="pl-2 flex flex-col gap-3">
 | 
				
			||||||
 | 
											for i, expiry := range expirations {
 | 
				
			||||||
 | 
												<div class="flex items-start gap-3">
 | 
				
			||||||
 | 
													@radio.Radio(radio.Props{
 | 
				
			||||||
 | 
														ID:      expiry.DurationCode,
 | 
				
			||||||
 | 
														Name:    "expiry",
 | 
				
			||||||
 | 
														Value:   expiry.DurationCode,
 | 
				
			||||||
 | 
														Checked: i == 0,
 | 
				
			||||||
 | 
													})
 | 
				
			||||||
 | 
													@label.Label(label.Props{
 | 
				
			||||||
 | 
														For: expiry.DurationCode,
 | 
				
			||||||
 | 
													}) {
 | 
				
			||||||
 | 
														{ expiry.DurationName }
 | 
				
			||||||
 | 
													}
 | 
				
			||||||
 | 
												</div>
 | 
				
			||||||
 | 
											}
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
										@button.Button(button.Props{
 | 
				
			||||||
 | 
											Type: "submit",
 | 
				
			||||||
 | 
										}) {
 | 
				
			||||||
 | 
											Upload
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
									</div>
 | 
				
			||||||
 | 
								</form>
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										55
									
								
								utils/templui.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								utils/templui.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,55 @@
 | 
				
			|||||||
 | 
					// templui util templui.go - version: v0.93.0 installed by templui v0.93.0
 | 
				
			||||||
 | 
					package utils
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"crypto/rand"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/a-h/templ"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						twmerge "github.com/Oudwins/tailwind-merge-go"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// TwMerge combines Tailwind classes and resolves conflicts.
 | 
				
			||||||
 | 
					// Example: "bg-red-500 hover:bg-blue-500", "bg-green-500" → "hover:bg-blue-500 bg-green-500"
 | 
				
			||||||
 | 
					func TwMerge(classes ...string) string {
 | 
				
			||||||
 | 
						return twmerge.Merge(classes...)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// TwIf returns value if condition is true, otherwise an empty value of type T.
 | 
				
			||||||
 | 
					// Example: true, "bg-red-500" → "bg-red-500"
 | 
				
			||||||
 | 
					func If[T comparable](condition bool, value T) T {
 | 
				
			||||||
 | 
						var empty T
 | 
				
			||||||
 | 
						if condition {
 | 
				
			||||||
 | 
							return value
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return empty
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// TwIfElse returns trueValue if condition is true, otherwise falseValue.
 | 
				
			||||||
 | 
					// Example: true, "bg-red-500", "bg-gray-300" → "bg-red-500"
 | 
				
			||||||
 | 
					func IfElse[T any](condition bool, trueValue T, falseValue T) T {
 | 
				
			||||||
 | 
						if condition {
 | 
				
			||||||
 | 
							return trueValue
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return falseValue
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// MergeAttributes combines multiple Attributes into one.
 | 
				
			||||||
 | 
					// Example: MergeAttributes(attr1, attr2) → combined attributes
 | 
				
			||||||
 | 
					func MergeAttributes(attrs ...templ.Attributes) templ.Attributes {
 | 
				
			||||||
 | 
						merged := templ.Attributes{}
 | 
				
			||||||
 | 
						for _, attr := range attrs {
 | 
				
			||||||
 | 
							for k, v := range attr {
 | 
				
			||||||
 | 
								merged[k] = v
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return merged
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// RandomID generates a random ID string.
 | 
				
			||||||
 | 
					// Example: RandomID() → "id-1a2b3c"
 | 
				
			||||||
 | 
					func RandomID() string {
 | 
				
			||||||
 | 
						return fmt.Sprintf("id-%s", rand.Text())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user