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