first iteration on frontend, done by claude because i'm lazy with frontend

This commit is contained in:
2026-05-20 12:02:51 -06:00
parent 887ad1e981
commit 64efed0809
49 changed files with 11864 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+20
View File
@@ -0,0 +1,20 @@
import client from './client'
import type { User } from '@/stores/auth'
export interface LoginPayload {
email: string
password: string
}
export interface AuthResponse {
token: string
user: User
}
export const authApi = {
login: (payload: LoginPayload) =>
client.post<AuthResponse>('/auth/login', payload),
me: () =>
client.get<User>('/me'),
}
+25
View File
@@ -0,0 +1,25 @@
import axios from 'axios'
const client = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
})
client.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
client.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(err)
}
)
export default client
+28
View File
@@ -0,0 +1,28 @@
<template>
<div>
<AppSidebar :open="sidebarOpen" />
<div
class="ease-soft-in-out xl:ml-68.5 relative h-full max-h-screen rounded-xl transition-all duration-200"
@click="sidebarOpen = false"
>
<AppNavbar :title="title" @toggle-sidebar="sidebarOpen = !sidebarOpen" />
<div class="w-full px-6 py-6 mx-auto">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import AppSidebar from './AppSidebar.vue'
import AppNavbar from './AppNavbar.vue'
const route = useRoute()
const sidebarOpen = ref(false)
const title = String(route.meta.title ?? '')
</script>
+73
View File
@@ -0,0 +1,73 @@
<template>
<nav class="relative flex flex-wrap items-center justify-between px-0 py-2 mx-6 transition-all shadow-none duration-250 ease-soft-in rounded-2xl lg:flex-nowrap lg:justify-start">
<div class="flex items-center justify-between w-full px-4 py-1 mx-auto flex-wrap-inherit">
<nav aria-label="breadcrumb">
<ol class="flex flex-wrap pt-1 mr-12 bg-transparent rounded-lg sm:mr-16">
<li class="text-sm leading-normal">
<RouterLink class="opacity-50 text-slate-700" to="/">Pages</RouterLink>
</li>
<li
class="text-sm pl-2 capitalize leading-normal text-slate-700 before:float-left before:pr-2 before:content-['/']"
aria-current="page"
>
{{ title }}
</li>
</ol>
<h6 class="mb-0 font-bold capitalize">{{ title }}</h6>
</nav>
<button
type="button"
class="inline-block p-0 font-bold text-center uppercase align-middle transition-all ease-in border-0 rounded-lg cursor-pointer text-xs leading-pro xl:hidden"
@click="$emit('toggle-sidebar')"
aria-label="Toggle navigation"
>
<i class="fas fa-bars text-slate-700"></i>
</button>
<div class="flex items-center mt-2 grow sm:mt-0 sm:mr-6 md:mr-0 lg:flex lg:basis-auto">
<div class="flex items-center md:ml-auto md:pr-4">
<div class="relative flex flex-wrap items-stretch w-full transition-all rounded-lg ease-soft">
<span class="text-sm ease-soft leading-5.6 absolute z-50 -ml-px flex h-full items-center whitespace-nowrap rounded-lg rounded-tr-none rounded-br-none border border-r-0 border-transparent bg-transparent py-2 px-2.5 text-center font-normal text-slate-500 transition-all">
<i class="fas fa-search" aria-hidden="true"></i>
</span>
<input
type="text"
class="pl-8.75 text-sm focus:shadow-soft-primary-outline ease-soft w-1/100 leading-5.6 relative -ml-px block min-w-0 flex-auto rounded-lg border border-solid border-gray-300 bg-white bg-clip-padding py-2 pr-3 text-gray-700 transition-all placeholder:text-gray-500 focus:border-fuchsia-300 focus:outline-none focus:transition-shadow"
placeholder="Search..."
/>
</div>
</div>
<ul class="flex flex-row justify-end pl-0 mb-0 list-none md-max:w-full">
<li class="flex items-center">
<button
class="block px-0 py-2 font-semibold transition-all ease-nav-brand text-sm text-slate-500 hover:text-slate-700 bg-transparent border-0 cursor-pointer"
@click="handleLogout"
>
<i class="fas fa-sign-out-alt text-lg mr-1"></i>
<span class="sm:inline">{{ auth.user?.name ?? 'Account' }}</span>
</button>
</li>
</ul>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
defineProps<{ title: string }>()
defineEmits<{ 'toggle-sidebar': [] }>()
const auth = useAuthStore()
const router = useRouter()
function handleLogout() {
auth.logout()
router.push({ name: 'login' })
}
</script>
+93
View File
@@ -0,0 +1,93 @@
<template>
<aside
id="sidenav-main"
:class="[
'max-w-62.5 ease-nav-brand z-990 fixed inset-y-0 my-4 ml-4 block w-full flex-wrap items-center justify-between overflow-y-auto rounded-2xl border-0 bg-white p-0 antialiased shadow-none transition-transform duration-200 xl:left-0 xl:translate-x-0 xl:bg-transparent',
open ? 'translate-x-0' : '-translate-x-full',
]"
>
<div class="h-19.5">
<RouterLink
to="/dashboard"
class="block px-8 py-6 m-0 text-sm whitespace-nowrap text-slate-700"
>
<span class="ml-1 font-semibold transition-all duration-200 ease-nav-brand">
SoloPM
</span>
</RouterLink>
</div>
<hr class="h-px mt-0 bg-transparent bg-gradient-horizontal-dark" />
<div class="items-center block w-auto max-h-screen overflow-auto h-sidenav">
<ul class="flex flex-col pl-0 mb-0 list-none">
<li class="mt-0.5 w-full">
<RouterLink
to="/dashboard"
custom
v-slot="{ isActive, href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="[
'py-2.7 text-sm ease-nav-brand my-0 mx-4 flex items-center whitespace-nowrap rounded-lg px-4 font-semibold transition-colors',
isActive
? 'shadow-soft-xl bg-white text-slate-700'
: 'text-slate-500 hover:text-slate-700',
]"
>
<div
:class="[
'mr-2 flex h-8 w-8 items-center justify-center rounded-lg bg-center stroke-0 text-center xl:p-2.5',
isActive
? 'bg-gradient-to-tl from-purple-700 to-pink-500 shadow-soft-2xl'
: 'bg-white shadow-soft-sm',
]"
>
<i
:class="[
'fas fa-tachometer-alt text-sm',
isActive ? 'text-white' : 'text-slate-700',
]"
></i>
</div>
Dashboard
</a>
</RouterLink>
</li>
<li class="pt-4 ml-2 pl-6 mt-4">
<h6 class="pl-6 ml-2 font-bold leading-tight uppercase text-xs opacity-60">
Projects
</h6>
</li>
</ul>
</div>
<div class="mx-4 mt-4">
<div class="relative flex min-w-0 flex-col items-center break-words rounded-2xl border-0 bg-white bg-clip-border shadow-none">
<div class="mb-7.5 h-28 w-full overflow-hidden rounded-xl">
<div class="bg-gradient-to-tl from-slate-600 to-slate-300 h-full w-full"></div>
</div>
<div class="-mt-14 w-3/4 text-center">
<p class="mt-0 mb-4 font-semibold leading-tight text-xs">Need help?</p>
<a
href="#"
class="inline-block w-full px-8 py-2 mb-4 font-bold text-center text-black uppercase transition-all ease-in bg-white border-0 rounded-lg shadow-soft-md bg-150 leading-pro text-xs hover:shadow-soft-2xl hover:scale-102"
>
Documentation
</a>
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
defineProps<{ open: boolean }>()
</script>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './styles/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+109
View File
@@ -0,0 +1,109 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="w-full max-w-md px-4">
<div class="relative flex flex-col min-w-0 break-words bg-white shadow-soft-xl rounded-2xl bg-clip-border">
<div class="p-6 pb-0 text-center">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-gradient-to-tl from-purple-700 to-pink-500 shadow-soft-2xl mb-4">
<i class="fas fa-rocket text-white text-xl"></i>
</div>
<h5 class="font-bold">Sign in to SoloPM</h5>
<p class="mb-0 text-sm">Enter your credentials to continue</p>
</div>
<div class="p-6">
<form @submit.prevent="handleLogin">
<div class="mb-4">
<label class="mb-2 text-xs font-semibold text-slate-700 uppercase">Email</label>
<input
v-model="form.email"
type="email"
required
placeholder="you@example.com"
class="text-sm focus:shadow-soft-primary-outline ease-soft block w-full rounded-lg border border-solid border-gray-300 bg-white bg-clip-padding py-2 px-3 text-gray-700 transition-all placeholder:text-gray-500 focus:border-fuchsia-300 focus:outline-none focus:transition-shadow"
/>
</div>
<div class="mb-4">
<label class="mb-2 text-xs font-semibold text-slate-700 uppercase">Password</label>
<input
v-model="form.password"
type="password"
required
placeholder="••••••••"
class="text-sm focus:shadow-soft-primary-outline ease-soft block w-full rounded-lg border border-solid border-gray-300 bg-white bg-clip-padding py-2 px-3 text-gray-700 transition-all placeholder:text-gray-500 focus:border-fuchsia-300 focus:outline-none focus:transition-shadow"
/>
</div>
<p v-if="error" class="text-xs text-red-600 mb-3">{{ error }}</p>
<button
type="submit"
:disabled="loading"
class="inline-block w-full px-6 py-3 font-bold text-center text-white uppercase align-middle transition-all ease-in border-0 rounded-lg select-none shadow-soft-md bg-150 bg-x-25 leading-pro text-xs bg-gradient-to-tl from-purple-700 to-pink-500 hover:shadow-soft-2xl hover:scale-102 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:scale-100"
>
{{ loading ? 'Signing in…' : 'Sign In' }}
</button>
</form>
<div class="relative my-5 flex items-center">
<div class="flex-grow border-t border-gray-300"></div>
<span class="mx-3 text-xs text-slate-500 uppercase font-semibold">or</span>
<div class="flex-grow border-t border-gray-300"></div>
</div>
<div class="flex flex-col gap-3">
<a
href="/api/auth/github"
class="inline-flex items-center justify-center w-full px-6 py-2.5 font-bold text-center text-slate-700 uppercase transition-all ease-in bg-white border border-solid border-gray-300 rounded-lg shadow-soft-sm text-xs hover:shadow-soft-md hover:scale-102"
>
<i class="fab fa-github mr-2 text-base"></i>
Sign in with GitHub
</a>
<a
href="/api/auth/gitea"
class="inline-flex items-center justify-center w-full px-6 py-2.5 font-bold text-center text-slate-700 uppercase transition-all ease-in bg-white border border-solid border-gray-300 rounded-lg shadow-soft-sm text-xs hover:shadow-soft-md hover:scale-102"
>
<i class="fas fa-code-branch mr-2 text-base"></i>
Sign in with Gitea
</a>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/auth'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const form = ref({ email: '', password: '' })
const loading = ref(false)
const error = ref('')
async function handleLogin() {
error.value = ''
loading.value = true
try {
const { data } = await authApi.login(form.value)
auth.setToken(data.token)
auth.setUser(data.user)
const redirect = String(route.query.redirect || '/dashboard')
router.push(redirect)
} catch {
error.value = 'Invalid email or password.'
} finally {
loading.value = false
}
}
</script>
+40
View File
@@ -0,0 +1,40 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="text-center">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-gradient-to-tl from-purple-700 to-pink-500 shadow-soft-2xl mb-4">
<i class="fas fa-spinner fa-spin text-white text-xl"></i>
</div>
<p v-if="error" class="text-sm text-red-600 mt-2">{{ error }}</p>
<p v-else class="text-sm text-slate-500 mt-2">Signing you in</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/auth'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const error = ref('')
onMounted(async () => {
const token = String(route.query.token ?? '')
if (token) {
auth.setToken(token)
try {
const { data } = await authApi.me()
auth.setUser(data)
router.replace('/dashboard')
} catch {
error.value = 'Failed to load your profile. Please try again.'
auth.logout()
}
return
}
error.value = 'No token received. Please try signing in again.'
})
</script>
+113
View File
@@ -0,0 +1,113 @@
<template>
<AppLayout>
<div class="flex flex-wrap -mx-3">
<div class="w-full max-w-full px-3 mb-6 sm:w-1/2 sm:flex-none xl:mb-0 xl:w-1/4">
<div class="relative flex flex-col min-w-0 break-words bg-white shadow-soft-xl rounded-2xl bg-clip-border">
<div class="flex-auto p-4">
<div class="flex flex-row -mx-3">
<div class="flex-none w-2/3 max-w-full px-3">
<p class="mb-0 font-sans font-semibold leading-normal text-sm">Projects</p>
<h5 class="mb-0 font-bold"></h5>
</div>
<div class="px-3 text-right basis-1/3">
<div class="inline-flex w-12 h-12 text-center rounded-lg items-center justify-center bg-gradient-to-tl from-purple-700 to-pink-500 shadow-soft-2xl">
<i class="fas fa-layer-group text-white text-lg relative"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="w-full max-w-full px-3 mb-6 sm:w-1/2 sm:flex-none xl:mb-0 xl:w-1/4">
<div class="relative flex flex-col min-w-0 break-words bg-white shadow-soft-xl rounded-2xl bg-clip-border">
<div class="flex-auto p-4">
<div class="flex flex-row -mx-3">
<div class="flex-none w-2/3 max-w-full px-3">
<p class="mb-0 font-sans font-semibold leading-normal text-sm">Open Tasks</p>
<h5 class="mb-0 font-bold"></h5>
</div>
<div class="px-3 text-right basis-1/3">
<div class="inline-flex w-12 h-12 text-center rounded-lg items-center justify-center bg-gradient-to-tl from-blue-600 to-cyan-400 shadow-soft-2xl">
<i class="fas fa-tasks text-white text-lg relative"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="w-full max-w-full px-3 mb-6 sm:w-1/2 sm:flex-none xl:mb-0 xl:w-1/4">
<div class="relative flex flex-col min-w-0 break-words bg-white shadow-soft-xl rounded-2xl bg-clip-border">
<div class="flex-auto p-4">
<div class="flex flex-row -mx-3">
<div class="flex-none w-2/3 max-w-full px-3">
<p class="mb-0 font-sans font-semibold leading-normal text-sm">In Progress</p>
<h5 class="mb-0 font-bold"></h5>
</div>
<div class="px-3 text-right basis-1/3">
<div class="inline-flex w-12 h-12 text-center rounded-lg items-center justify-center bg-gradient-to-tl from-green-600 to-lime-400 shadow-soft-2xl">
<i class="fas fa-spinner text-white text-lg relative"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="w-full max-w-full px-3 mb-6 sm:w-1/2 sm:flex-none xl:mb-0 xl:w-1/4">
<div class="relative flex flex-col min-w-0 break-words bg-white shadow-soft-xl rounded-2xl bg-clip-border">
<div class="flex-auto p-4">
<div class="flex flex-row -mx-3">
<div class="flex-none w-2/3 max-w-full px-3">
<p class="mb-0 font-sans font-semibold leading-normal text-sm">Completed</p>
<h5 class="mb-0 font-bold"></h5>
</div>
<div class="px-3 text-right basis-1/3">
<div class="inline-flex w-12 h-12 text-center rounded-lg items-center justify-center bg-gradient-to-tl from-slate-600 to-slate-300 shadow-soft-2xl">
<i class="fas fa-check-circle text-white text-lg relative"></i>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap mt-6 -mx-3">
<div class="w-full max-w-full px-3 mt-0 lg:w-7/12 lg:flex-none">
<div class="relative flex flex-col min-w-0 break-words bg-white border-0 shadow-soft-xl rounded-2xl bg-clip-border">
<div class="border-black/12.5 rounded-t-2xl border-b-0 border-solid p-6 pb-0">
<h6 class="capitalize">Recent Projects</h6>
<p class="text-sm leading-normal">Your active projects</p>
</div>
<div class="flex-auto p-6 pt-4">
<p class="text-sm text-slate-500 text-center py-8">No projects yet.</p>
</div>
</div>
</div>
<div class="w-full max-w-full px-3 mt-4 lg:w-5/12 lg:flex-none lg:mt-0">
<div class="relative flex flex-col min-w-0 break-words bg-white border-0 shadow-soft-xl rounded-2xl bg-clip-border">
<div class="border-black/12.5 rounded-t-2xl border-b-0 border-solid p-6 pb-0">
<h6 class="capitalize">Activity</h6>
<p class="text-sm leading-normal">Recent updates</p>
</div>
<div class="flex-auto p-6 pt-4">
<p class="text-sm text-slate-500 text-center py-8">No activity yet.</p>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import AppLayout from '@/components/layout/AppLayout.vue'
</script>
+42
View File
@@ -0,0 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/pages/auth/LoginPage.vue'),
meta: { public: true },
},
{
path: '/auth/callback',
name: 'oauth-callback',
component: () => import('@/pages/auth/OAuthCallback.vue'),
meta: { public: true },
},
{
path: '/',
redirect: '/dashboard',
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/pages/dashboard/DashboardPage.vue'),
meta: { title: 'Dashboard' },
},
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (!to.meta.public && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && auth.isAuthenticated) {
return { name: 'dashboard' }
}
})
export default router
+33
View File
@@ -0,0 +1,33 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface User {
id: number
name: string
email: string
avatar_url?: string
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token'))
const user = ref<User | null>(null)
const isAuthenticated = computed(() => !!token.value)
function setToken(t: string) {
token.value = t
localStorage.setItem('token', t)
}
function setUser(u: User) {
user.value = u
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem('token')
}
return { token, user, isAuthenticated, setToken, setUser, logout }
})
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />