Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ui/apps/portal/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { Toast } from '@quixsi/components'
import { useToast } from '@/composables/useToast'

const { toasts, removeToast } = useToast()
</script>

<template>
<Toast :toasts="toasts" @remove="removeToast" />
<RouterView />
</template>
1 change: 0 additions & 1 deletion ui/apps/portal/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ a,

#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}
53 changes: 53 additions & 0 deletions ui/apps/portal/src/composables/useToast.ts
Copy link
Copy Markdown
Contributor

@schogges schogges Aug 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally this is part of the components package. Did you use npx shadcn-vue@latest add toast to add the component? (ref: https://www.shadcn-vue.com/docs/components/toast.html)

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ref } from 'vue'

export type ToastType = 'success' | 'error'

export interface ToastState {
message: string
type: ToastType
key: number
duration?: number
class?: string
}

const toasts = ref<ToastState[]>([])
const timers = new Map<number, ReturnType<typeof setTimeout>>()

export function useToast() {
const scheduleRemoval = (key: number, duration = 3000) => {
if (timers.has(key)) return
const t = setTimeout(() => {
removeToast(key)
}, duration)
timers.set(key, t)
}

const showSuccess = (message: string, duration = 3000) => {
const key = Date.now() + Math.random()
toasts.value.push({ message, type: 'success', key, duration })
scheduleRemoval(key, duration)
}

const showError = (message: string, duration = 3000) => {
const key = Date.now() + Math.random()
toasts.value.push({ message, type: 'error', key, duration })
scheduleRemoval(key, duration)
}

const removeToast = (key: string | number) => {
toasts.value = toasts.value.filter(t => t.key !== key)
const timer = timers.get(Number(key))
if (timer) {
clearTimeout(timer)
timers.delete(Number(key))
}
}

const clearToasts = () => {
toasts.value = []
timers.forEach(t => clearTimeout(t))
timers.clear()
}

return { toasts, showSuccess, showError, removeToast, clearToasts }
}
74 changes: 74 additions & 0 deletions ui/apps/portal/src/composables/useUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ref } from 'vue'
import { apiService, type User } from '@/services/api'
import { useToast } from '@/composables/useToast'

const { showSuccess, showError } = useToast()
const users = ref<User[]>([])
const loading = ref(true)
const error = ref('')

const fetchUsers = async () => {
loading.value = true
error.value = ''

try {
users.value = await apiService.getUsers()
} catch (e: any) {
error.value = e.message || 'Failed to load users.'
showError(error.value)
} finally {
loading.value = false
}
}

const createUser = async (name: string, email: string) => {
error.value = ''
try {
if (!name.trim()) {
throw new Error('Name is required.')
}
if (!email.trim()) {
throw new Error('Email is required.')
}

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
throw new Error('Invalid email address.')
}

if (users.value.some(u => u.email.toLowerCase() === email.trim().toLowerCase())) {
throw new Error('Email is already in use.')
}

await apiService.createUser({ name, email })
await fetchUsers()
showSuccess('User created successfully!')
} catch (e: any) {
error.value = e.message || 'Failed to create user.'
showError(error.value)
}
}

const deleteUser = async (id: string) => {
error.value = ''

try {
await apiService.deleteUser(id)
await fetchUsers()
showSuccess('User deleted successfully!')
} catch (e: any) {
error.value = e.message || 'Failed to delete user.'
showError(error.value)
}
}

export function useUsers() {
return {
users,
loading,
error,
fetchUsers,
createUser,
deleteUser,
}
}
7 changes: 7 additions & 0 deletions ui/apps/portal/src/router/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'

import HomeView from '../views/HomeView.vue'
import { UsersView } from '../views/users'

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
Expand All @@ -9,6 +11,11 @@ const router = createRouter({
name: 'home',
component: HomeView,
},
{
path: '/users',
name: 'users',
component: UsersView,
},
],
})

Expand Down
4 changes: 4 additions & 0 deletions ui/apps/portal/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export class ApiService {
throw new Error(`API request failed: ${response.statusText}`)
}

if (response.status === 204) {
// No content to parse
return undefined as T
}
return response.json()
}

Expand Down
7 changes: 6 additions & 1 deletion ui/apps/portal/src/views/HomeView.vue
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
<template>
Home
<div class="space-y-4">
<h1 class="text-2xl font-bold">Home</h1>
<router-link to="/users" class="text-blue-600 underline hover:text-blue-800">
Go to Users
</router-link>
</div>
</template>
59 changes: 59 additions & 0 deletions ui/apps/portal/src/views/users/UsersView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<template>
<div>
<h1 class="text-2xl font-bold mb-4">Users</h1>
<Card class="max-w-2xl mx-auto my-8">
<template #content>
<div v-if="loading" class="mb-4">Loading users...</div>
<table v-if="users.length" class="min-w-full">
<thead>
<tr>
<th class="px-4 py-2 border-b">Name</th>
<th class="px-4 py-2 border-b">Email</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td class="px-4 py-2 border-b flex items-center gap-2">
{{ user.name }}
<button
@click="deleteUser(user.id)"
title="Delete user"
class="text-red-500 hover:text-red-700 cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="inline w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</td>
<td class="px-4 py-2 border-b">{{ user.email }}</td>
</tr>
</tbody>
</table>
<div v-else class="text-gray-500">No users found.</div>
</template>
</Card>
<CreateUser />
</div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue'
import { Card } from '@quixsi/components'
import { CreateUser } from './create'
import { useUsers } from '@/composables/useUsers'

const { users, loading, fetchUsers, deleteUser } = useUsers()

onMounted(fetchUsers)
</script>
37 changes: 37 additions & 0 deletions ui/apps/portal/src/views/users/create/CreateUser.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<template>
<div class="max-w-2xl mx-auto mt-2 flex flex-col gap-2">
<h3 class="text-lg font-semibold mb-2">Create User</h3>
<div class="flex gap-2">
<Input
v-model="newName"
placeholder="Name"
class="flex-1"
/>
<Input
v-model="newEmail"
placeholder="Email"
class="flex-1"
/>
</div>
<Button
class="bg-slate-500 hover:bg-blue-400 text-white"
@click="createNewUser"
>
Create new user
</Button>
Comment on lines +4 to +21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use a native <form> for this?

</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Input, Button } from '@quixsi/components'
import { useUsers } from '@/composables/useUsers'

const newName = ref('')
const newEmail = ref('')
const { createUser, error } = useUsers()

const createNewUser = async () => {
await createUser(newName.value, newEmail.value)
}
</script>
1 change: 1 addition & 0 deletions ui/apps/portal/src/views/users/create/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as CreateUser } from './CreateUser.vue'
2 changes: 2 additions & 0 deletions ui/apps/portal/src/views/users/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as UsersView } from './UsersView.vue'
export { CreateUser } from './create'
5 changes: 5 additions & 0 deletions ui/apps/portal/src/vue-shims.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
3 changes: 2 additions & 1 deletion ui/packages/components/src/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./button"
export * from "./input"
export * from "./checkbox"
export * from "./card"
export * from "./card"
export * from "./toast"
52 changes: 52 additions & 0 deletions ui/packages/components/src/ui/toast/Toast.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '#lib/utils'

defineProps<{
toasts: Array<{
message: string
type?: 'success' | 'error'
key: string | number
class?: HTMLAttributes['class']
duration?: number
}>
}>()

const emit = defineEmits<{
(e: 'remove', key: string | number): void
}>()

const handleRemove = (key: string | number) => {
emit('remove', key)
}
</script>

<template>
<div class="fixed z-50 top-8 right-8 flex flex-col gap-2 items-end">
<transition-group name="fade" tag="div">
<div
v-for="item in toasts"
:key="item.key"
data-slot="toast"
@click="handleRemove(item.key)"
:class="cn(
'px-4 py-2 mb-3 rounded shadow-lg cursor-pointer',
item.type === 'error' ? 'bg-red-500 text-white' : 'bg-green-500 text-white',
item.class,
)"
>
{{ item.message }}
</div>
</transition-group>
</div>
</template>

<style scoped>
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}

.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>
1 change: 1 addition & 0 deletions ui/packages/components/src/ui/toast/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Toast } from './Toast.vue'