first iteration on frontend, done by claude because i'm lazy with frontend
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -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'),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user