Fix: Refactor dashboard state management for reactivity

This commit is contained in:
2025-10-10 11:14:29 -07:00
parent 46b460f511
commit e06fb29efb

View File

@@ -6,14 +6,14 @@
<title>Dashboard - 4ev.link</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🔗</text></svg>">
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="//unpkg.com/alpinejs" defer></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
</head>
<body class="bg-slate-50 text-slate-800 font-sans">
<script>if (!localStorage.getItem('username')) window.location.href = '/';</script>
<div x-data="{ user: localStorage.getItem('username'), sidebarOpen: false, editingSlug: null }" class="min-h-screen flex flex-col">
<div x-data="dashboard()" x-init="fetchLinks()" @link-created.window="fetchLinks()" @keydown.escape.window="sidebarOpen = false" class="min-h-screen flex flex-col">
<header class="bg-white/80 backdrop-blur-sm border-b border-slate-200 sticky top-0 z-10">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
@@ -38,7 +38,7 @@
<div class="max-w-5xl mx-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-2xl mx-auto">
<div x-data="linkForm()" class="bg-white p-8 rounded-xl shadow-sm border border-slate-200">
<template x-if="!$root.editingSlug">
<template x-if="!editingSlug">
<div>
<h1 class="text-2xl font-bold mb-1">Create a new link</h1>
<p class="text-slate-500 mb-6">Shorten a long URL into a memorable link.</p>
@@ -79,13 +79,13 @@
</div>
</template>
<template x-if="$root.editingSlug">
<div x-data="editForm()">
<button @click="$root.editingSlug = null" class="text-slate-500 hover:text-slate-900 mb-4 flex items-center gap-1">
<template x-if="editingSlug">
<div x-data="editForm()" x-init="init()">
<button @click="editingSlug = null" class="text-slate-500 hover:text-slate-900 mb-4 flex items-center gap-1">
<i data-lucide="arrow-left" class="w-4 h-4"></i> Back
</button>
<h1 class="text-2xl font-bold mb-1">Edit link</h1>
<p class="text-slate-500 mb-6 font-mono" x-text="'4ev.link/' + $root.editingSlug"></p>
<p class="text-slate-500 mb-6 font-mono" x-text="'4ev.link/' + editingSlug"></p>
<form @submit.prevent="updateLink" class="space-y-4">
<div>
@@ -109,18 +109,18 @@
</div>
</div>
<div class="max-w-2xl mx-auto mt-12" x-data="linkList()" x-init="fetchLinks()" @link-created.window="fetchLinks()" x-show="!$root.editingSlug">
<div class="max-w-2xl mx-auto mt-12" x-show="!editingSlug">
<h2 class="text-2xl font-bold mb-4">Your Links</h2>
<div class="bg-white p-6 rounded-xl shadow-sm border border-slate-200 min-h-[10rem] flex flex-col justify-center">
<template x-if="loading">
<template x-if="loadingLinks">
<div class="flex justify-center items-center p-8">
<i data-lucide="loader-2" class="animate-spin w-8 h-8 text-slate-400"></i>
</div>
</template>
<template x-if="!loading && error">
<p class="text-rose-500 text-center" x-text="error"></p>
<template x-if="!loadingLinks && errorLinks">
<p class="text-rose-500 text-center" x-text="errorLinks"></p>
</template>
<template x-if="!loading && !error">
<template x-if="!loadingLinks && !errorLinks">
<div>
<template x-if="links.length === 0">
<p class="text-slate-500 text-center py-4">You haven't created any links yet.</p>
@@ -145,30 +145,30 @@
</div>
</main>
<aside x-show="sidebarOpen" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform translate-x-full" x-transition:enter-end="opacity-100 transform translate-x-0" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 transform translate-x-0" x-transition:leave-end="opacity-0 transform translate-x-full" class="fixed right-0 top-16 bottom-0 w-80 bg-white border-l border-slate-200 shadow-lg z-20 overflow-y-auto" style="display: none;">
<div x-data="sidebarLinks()" x-init="fetchLinks()" @link-created.window="fetchLinks()" class="p-4">
<aside x-show="sidebarOpen" @click.away="sidebarOpen = false" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform translate-x-full" x-transition:enter-end="opacity-100 transform translate-x-0" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 transform translate-x-0" x-transition:leave-end="opacity-0 transform translate-x-full" class="fixed right-0 top-16 bottom-0 w-80 bg-white border-l border-slate-200 shadow-lg z-20 overflow-y-auto" style="display: none;">
<div class="p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-lg">Your Links</h3>
<button @click="$root.sidebarOpen = false" class="text-slate-500 hover:text-slate-900">
<button @click="sidebarOpen = false" class="text-slate-500 hover:text-slate-900">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<template x-if="loading">
<template x-if="loadingLinks">
<div class="flex justify-center p-8">
<i data-lucide="loader-2" class="animate-spin w-6 h-6 text-slate-400"></i>
</div>
</template>
<template x-if="!loading && error">
<p class="text-rose-500 text-sm text-center" x-text="error"></p>
<template x-if="!loadingLinks && errorLinks">
<p class="text-rose-500 text-sm text-center" x-text="errorLinks"></p>
</template>
<template x-if="!loading && !error && links.length === 0">
<template x-if="!loadingLinks && !errorLinks && links.length === 0">
<p class="text-slate-500 text-sm text-center py-8">No links yet</p>
</template>
<template x-if="!loading && !error && links.length > 0">
<template x-if="!loadingLinks && !errorLinks && links.length > 0">
<ul class="space-y-2">
<template x-for="slug in links" :key="slug">
<li>
<button @click="$root.editingSlug = slug; $root.sidebarOpen = false" class="w-full text-left p-3 bg-slate-50 hover:bg-slate-100 rounded-md border border-slate-200 transition-colors font-mono text-sm" x-text="slug"></button>
<button @click="editingSlug = slug; sidebarOpen = false" class="w-full text-left p-3 bg-slate-50 hover:bg-slate-100 rounded-md border border-slate-200 transition-colors font-mono text-sm" x-text="slug"></button>
</li>
</template>
</ul>
@@ -179,10 +179,9 @@
</div>
<script>
function dashboard(){return{user:localStorage.getItem("username"),sidebarOpen:!1,editingSlug:null,links:[],loadingLinks:!0,errorLinks:"",async fetchLinks(){this.loadingLinks=!0,this.errorLinks="";try{const e=localStorage.getItem("username"),t=localStorage.getItem("pass_hash");if(!e||!t)throw new Error("Authentication error.");const s=await fetch("/api/links/list",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:e,pass_hash:t})});if(!s.ok)throw new Error(await s.text()||"Failed to fetch links.");this.links=(await s.json()).reverse()}catch(e){this.errorLinks=e.message}finally{this.loadingLinks=!1,this.$nextTick(()=>lucide.createIcons())}}}}
function linkForm(){return{destination_url:"",slug:"",loading:!1,error:"",result:{},copied:!1,async createLink(){this.loading=!0,this.error="",this.result={};const o=grecaptcha.getResponse();if(!o)return this.error="Please complete the CAPTCHA.",this.loading=!1,void 0;try{const e=localStorage.getItem("username"),s=localStorage.getItem("pass_hash");if(!e||!s)throw new Error("Authentication error. Please log in again.");const t=await fetch("/api/links/create",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({destination_url:this.destination_url,slug:this.slug||null,username:e,pass_hash:s,"g-recaptcha-response":o})});if(!t.ok)throw new Error(await t.text()||"Failed to create link.");const i=await t.json(),r=window.location.host;this.result={...i,url:`https://${r}/${i.slug}`},this.destination_url="",this.slug="",this.$dispatch("link-created"),this.$nextTick(()=>lucide.createIcons())}catch(e){this.error=e.message}finally{this.loading=!1,grecaptcha.reset()}}}}
function linkList(){return{links:[],loading:!0,error:"",async fetchLinks(){this.loading=!0,this.error="";try{const e=localStorage.getItem("username"),s=localStorage.getItem("pass_hash");if(!e||!s)throw new Error("Authentication error.");const t=await fetch("/api/links/list",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:e,pass_hash:s})});if(!t.ok)throw new Error(await t.text()||"Failed to fetch links.");this.links=(await t.json()).reverse()}catch(e){this.error=e.message}finally{this.loading=!1,this.$nextTick(()=>lucide.createIcons())}}}}
function sidebarLinks(){return{links:[],loading:!0,error:"",async fetchLinks(){this.loading=!0,this.error="";try{const e=localStorage.getItem("username"),s=localStorage.getItem("pass_hash");if(!e||!s)throw new Error("Authentication error.");const t=await fetch("/api/links/list",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:e,pass_hash:s})});if(!t.ok)throw new Error(await t.text()||"Failed to fetch links.");this.links=(await t.json()).reverse()}catch(e){this.error=e.message}finally{this.loading=!1,this.$nextTick(()=>lucide.createIcons())}}}}
function editForm(){return{destination_url:"",loading:!1,error:"",async init(){await this.loadCurrentUrl()},async loadCurrentUrl(){try{const e=await fetch(`/api/links/get?slug=${this.$root.editingSlug}`);if(!e.ok)throw new Error("Failed to load link");const s=await e.json();this.destination_url=s.destination_url.startsWith("http")?s.destination_url:`https://${s.destination_url}`}catch(e){this.error=e.message}},async updateLink(){this.loading=!0,this.error="";const o=grecaptcha.getResponse();if(!o)return this.error="Please complete the CAPTCHA.",this.loading=!1,void 0;try{const e=localStorage.getItem("username"),s=localStorage.getItem("pass_hash");if(!e||!s)throw new Error("Authentication error.");const t=await fetch("/api/links/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({slug:this.$root.editingSlug,destination_url:this.destination_url,username:e,pass_hash:s,"g-recaptcha-response":o})});if(!t.ok)throw new Error(await t.text()||"Failed to update link.");this.$root.editingSlug=null,this.$dispatch("link-created")}catch(e){this.error=e.message}finally{this.loading=!1,grecaptcha.reset()}},async deleteLink(){if(!confirm("Are you sure you want to delete this link?"))return;this.loading=!0,this.error="";const o=grecaptcha.getResponse();if(!o)return this.error="Please complete the CAPTCHA.",this.loading=!1,void 0;try{const e=localStorage.getItem("username"),s=localStorage.getItem("pass_hash");if(!e||!s)throw new Error("Authentication error.");const t=await fetch("/api/links/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({slug:this.$root.editingSlug,username:e,pass_hash:s,"g-recaptcha-response":o})});if(!t.ok)throw new Error(await t.text()||"Failed to delete link.");this.$root.editingSlug=null,this.$dispatch("link-created")}catch(e){this.error=e.message}finally{this.loading=!1,grecaptcha.reset()}}}}
function editForm(){return{destination_url:"",loading:!1,error:"",async init(){await this.loadCurrentUrl()},async loadCurrentUrl(){try{const e=await fetch(`/api/links/get?slug=${this.editingSlug}`);if(!e.ok)throw new Error("Failed to load link");const s=await e.json();this.destination_url=s.destination_url.startsWith("http")?s.destination_url:`https://${s.destination_url}`}catch(e){this.error=e.message}},async updateLink(){this.loading=!0,this.error="";const o=grecaptcha.getResponse();if(!o)return this.error="Please complete the CAPTCHA.",this.loading=!1,void 0;try{const e=localStorage.getItem("username"),s=localStorage.getItem("pass_hash");if(!e||!s)throw new Error("Authentication error.");const t=await fetch("/api/links/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({slug:this.editingSlug,destination_url:this.destination_url,username:e,pass_hash:s,"g-recaptcha-response":o})});if(!t.ok)throw new Error(await t.text()||"Failed to update link.");this.editingSlug=null,this.$dispatch("link-created")}catch(e){this.error=e.message}finally{this.loading=!1,grecaptcha.reset()}},async deleteLink(){if(!confirm("Are you sure you want to delete this link?"))return;this.loading=!0,this.error="";const o=grecaptcha.getResponse();if(!o)return this.error="Please complete the CAPTCHA.",this.loading=!1,void 0;try{const e=localStorage.getItem("username"),s=localStorage.getItem("pass_hash");if(!e||!s)throw new Error("Authentication error.");const t=await fetch("/api/links/delete",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({slug:this.editingSlug,username:e,pass_hash:s,"g-recaptcha-response":o})});if(!t.ok)throw new Error(await t.text()||"Failed to delete link.");this.editingSlug=null,this.$dispatch("link-created")}catch(e){this.error=e.message}finally{this.loading=!1,grecaptcha.reset()}}}}
lucide.createIcons();
</script>
</body>