feat(web): implement basic first project CRUD UI
This commit is contained in:
parent
32aed2af26
commit
568b04ca27
|
|
@ -71,6 +71,7 @@
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@workspace/schema$": "<rootDir>/../test/__mocks__/schema.ts",
|
"^@workspace/schema$": "<rootDir>/../test/__mocks__/schema.ts",
|
||||||
|
"^@workspace/schema/migrations$": "<rootDir>/../test/__mocks__/schema-migrations.ts",
|
||||||
"^valibot$": "<rootDir>/../test/__mocks__/valibot.ts"
|
"^valibot$": "<rootDir>/../test/__mocks__/valibot.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||||
import { Pool } from "pg";
|
import { Pool } from "pg";
|
||||||
|
import { migrationsDir } from "@workspace/schema/migrations";
|
||||||
import {
|
import {
|
||||||
drizzle,
|
drizzle,
|
||||||
NodePgDatabase,
|
NodePgDatabase,
|
||||||
|
|
@ -8,7 +9,6 @@ import {
|
||||||
tasks,
|
tasks,
|
||||||
projectsRelations,
|
projectsRelations,
|
||||||
tasksRelations,
|
tasksRelations,
|
||||||
migrationsDir,
|
|
||||||
} from "@workspace/schema";
|
} from "@workspace/schema";
|
||||||
|
|
||||||
const DEFAULT_PG_PORT = 5432;
|
const DEFAULT_PG_PORT = 5432;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const DEFAULT_PORT = 3001;
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.enableCors();
|
||||||
await app.listen(process.env.PORT ?? DEFAULT_PORT);
|
await app.listen(process.env.PORT ?? DEFAULT_PORT);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
|
|
||||||
1
apps/api/test/__mocks__/schema-migrations.ts
Normal file
1
apps/api/test/__mocks__/schema-migrations.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const migrationsDir = "/mock/migrations";
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@workspace/schema$": "<rootDir>/__mocks__/schema.ts",
|
"^@workspace/schema$": "<rootDir>/__mocks__/schema.ts",
|
||||||
|
"^@workspace/schema/migrations$": "<rootDir>/__mocks__/schema-migrations.ts",
|
||||||
"^valibot$": "<rootDir>/__mocks__/valibot.ts"
|
"^valibot$": "<rootDir>/__mocks__/valibot.ts"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,8 @@ export default defineAppConfig({
|
||||||
primary: "green",
|
primary: "green",
|
||||||
neutral: "slate",
|
neutral: "slate",
|
||||||
},
|
},
|
||||||
|
main: {
|
||||||
|
base: "min-h-0 flex-1 overflow-y-auto",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,52 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
useHead({
|
useHead({
|
||||||
meta: [
|
meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }],
|
||||||
{ name: "viewport", content: "width=device-width, initial-scale=1", },
|
link: [{ rel: "icon", href: "/favicon.ico" }],
|
||||||
],
|
|
||||||
link: [
|
|
||||||
{ rel: "icon", href: "/favicon.ico", },
|
|
||||||
],
|
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
lang: "en",
|
lang: "en",
|
||||||
},
|
},
|
||||||
},)
|
});
|
||||||
|
|
||||||
const title = "Nuxt Starter Template"
|
const title = "Work Hub";
|
||||||
const description = "A production-ready starter template powered by Nuxt UI. Build beautiful, accessible, and performant applications in minutes, not hours."
|
const description = "Project management made simple.";
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
ogTitle: title,
|
ogTitle: title,
|
||||||
ogDescription: description,
|
ogDescription: description,
|
||||||
ogImage: "https://ui.nuxt.com/assets/templates/nuxt/starter-light.png",
|
|
||||||
twitterCard: "summary_large_image",
|
twitterCard: "summary_large_image",
|
||||||
},)
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UApp>
|
<UApp>
|
||||||
|
<div class="flex flex-col min-h-screen">
|
||||||
<UHeader>
|
<UHeader>
|
||||||
<template #left>
|
<template #left>
|
||||||
<NuxtLink to="/">
|
<NuxtLink to="/">
|
||||||
<AppLogo class="w-auto h-6 shrink-0" />
|
<AppLogo class="w-auto h-6 shrink-0" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<TemplateMenu />
|
<UButton to="/projects" label="Projects" color="neutral" variant="ghost" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #right>
|
<template #right>
|
||||||
<UColorModeButton />
|
<UColorModeButton />
|
||||||
|
|
||||||
<UButton
|
|
||||||
to="https://github.com/nuxt-ui-templates/starter"
|
|
||||||
target="_blank"
|
|
||||||
icon="i-simple-icons-github"
|
|
||||||
aria-label="GitHub"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</UHeader>
|
</UHeader>
|
||||||
|
|
||||||
<UMain>
|
<UMain class="px-6">
|
||||||
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
</UMain>
|
</UMain>
|
||||||
|
|
||||||
<USeparator icon="i-simple-icons-nuxtdotjs" />
|
<USeparator icon="i-simple-icons-nuxtdotjs" />
|
||||||
|
|
||||||
<UFooter>
|
<UFooter>
|
||||||
<template #left>
|
<template #left>
|
||||||
<p class="text-sm text-muted">
|
<p class="text-sm text-muted">Built with Nuxt UI • © {{ new Date().getFullYear() }}</p>
|
||||||
Built with Nuxt UI • © {{ new Date().getFullYear() }}
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #right>
|
<template #right>
|
||||||
|
|
@ -69,9 +56,9 @@ useSeoMeta({
|
||||||
icon="i-simple-icons-github"
|
icon="i-simple-icons-github"
|
||||||
aria-label="GitHub"
|
aria-label="GitHub"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="ghost"
|
variant="ghost" />
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</UFooter>
|
</UFooter>
|
||||||
|
</div>
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
54
apps/web/app/components/ProjectForm.vue
Normal file
54
apps/web/app/components/ProjectForm.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { PROJECT_STATUSES, type ProjectStatus } from "@workspace/schema/constants";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: ProjectStatus;
|
||||||
|
saving?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [body: { name: string; description?: string; status: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const name = ref(props.name);
|
||||||
|
const description = ref(props.description);
|
||||||
|
const status = ref(props.status);
|
||||||
|
|
||||||
|
function onSubmit() {
|
||||||
|
emit("submit", {
|
||||||
|
name: name.value,
|
||||||
|
description: description.value || undefined,
|
||||||
|
status: status.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="space-y-6" @submit.prevent="onSubmit">
|
||||||
|
<UFormField label="Name" required>
|
||||||
|
<UInput v-model="name" placeholder="Project name" class="w-full" required />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Description">
|
||||||
|
<UTextarea v-model="description" placeholder="Optional description" class="w-full" :rows="3" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Status">
|
||||||
|
<USelect v-model="status" :items="PROJECT_STATUSES.map((s) => ({ label: s, value: s }))" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<UButton label="Cancel" color="neutral" variant="outline" to="/projects" />
|
||||||
|
<UButton type="submit" label="Save" color="primary" :loading="saving" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
@ -2,48 +2,55 @@
|
||||||
<UDropdownMenu
|
<UDropdownMenu
|
||||||
v-slot="{ open }"
|
v-slot="{ open }"
|
||||||
:modal="false"
|
:modal="false"
|
||||||
:items="[{
|
:items="[
|
||||||
|
{
|
||||||
label: 'Starter',
|
label: 'Starter',
|
||||||
to: 'https://starter-template.nuxt.dev/',
|
to: 'https://starter-template.nuxt.dev/',
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
checked: true,
|
checked: true,
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
label: 'Landing',
|
label: 'Landing',
|
||||||
to: 'https://landing-template.nuxt.dev/',
|
to: 'https://landing-template.nuxt.dev/',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
label: 'Docs',
|
label: 'Docs',
|
||||||
to: 'https://docs-template.nuxt.dev/',
|
to: 'https://docs-template.nuxt.dev/',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
label: 'SaaS',
|
label: 'SaaS',
|
||||||
to: 'https://saas-template.nuxt.dev/',
|
to: 'https://saas-template.nuxt.dev/',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
to: 'https://dashboard-template.nuxt.dev/',
|
to: 'https://dashboard-template.nuxt.dev/',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
label: 'Chat',
|
label: 'Chat',
|
||||||
to: 'https://chat-template.nuxt.dev/',
|
to: 'https://chat-template.nuxt.dev/',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
label: 'Portfolio',
|
label: 'Portfolio',
|
||||||
to: 'https://portfolio-template.nuxt.dev/',
|
to: 'https://portfolio-template.nuxt.dev/',
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
label: 'Changelog',
|
label: 'Changelog',
|
||||||
to: 'https://changelog-template.nuxt.dev/',
|
to: 'https://changelog-template.nuxt.dev/',
|
||||||
}]"
|
},
|
||||||
|
]"
|
||||||
:content="{ align: 'start' }"
|
:content="{ align: 'start' }"
|
||||||
:ui="{ content: 'min-w-fit' }"
|
:ui="{ content: 'min-w-fit' }"
|
||||||
size="xs"
|
size="xs">
|
||||||
>
|
|
||||||
<UButton
|
<UButton
|
||||||
label="Starter"
|
label="Starter"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
trailing-icon="i-lucide-chevron-down"
|
trailing-icon="i-lucide-chevron-down"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="-mb-[6px] font-semibold rounded-full truncate"
|
class="-mb-1.5 font-semibold rounded-full truncate"
|
||||||
:class="[open && 'bg-primary/15']"
|
:class="[open && 'bg-primary/15']"
|
||||||
:ui="{
|
:ui="{
|
||||||
trailingIcon: ['transition-transform duration-200', open ? 'rotate-180' : undefined].filter(Boolean).join(' '),
|
trailingIcon: ['transition-transform duration-200', open ? 'rotate-180' : undefined].filter(Boolean).join(' '),
|
||||||
}"
|
}" />
|
||||||
/>
|
|
||||||
</UDropdownMenu>
|
</UDropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Project } from "@workspace/schema";
|
import type { Project, ProjectInsertDTO, ProjectUpdateDTO } from "@workspace/schema";
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
data: T | null;
|
data: T | null;
|
||||||
|
|
@ -47,7 +47,7 @@ export function useProjectApi() {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createProject(body: Record<string, unknown>): Promise<Project | null> {
|
async function createProject(body: Partial<ProjectInsertDTO>): Promise<Project | null> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ export function useProjectApi() {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateProject(id: string, body: Record<string, unknown>): Promise<Project | null> {
|
async function updateProject(id: string, body: Partial<ProjectUpdateDTO>): Promise<Project | null> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ export function useProjectApi() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const idx = projects.value.findIndex((p) => p.id === id);
|
const idx = projects.value.findIndex(p => p.id === id);
|
||||||
if (idx !== NOT_FOUND) projects.value[idx] = data;
|
if (idx !== NOT_FOUND) projects.value[idx] = data;
|
||||||
} else {
|
} else {
|
||||||
error.value = err;
|
error.value = err;
|
||||||
|
|
@ -95,7 +95,7 @@ export function useProjectApi() {
|
||||||
if (err) {
|
if (err) {
|
||||||
error.value = err;
|
error.value = err;
|
||||||
} else {
|
} else {
|
||||||
projects.value = projects.value.filter((p) => p.id !== id);
|
projects.value = projects.value.filter(p => p.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
|
||||||
3
apps/web/app/layouts/default.vue
Normal file
3
apps/web/app/layouts/default.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<template>
|
||||||
|
<slot />
|
||||||
|
</template>
|
||||||
62
apps/web/app/pages/projects/[id]/edit.vue
Normal file
62
apps/web/app/pages/projects/[id]/edit.vue
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Project } from "@workspace/schema";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
title: "Edit Project",
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { updateProject } = useProjectApi();
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
const project = ref<Project | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const notFound = ref(false);
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBase = config.public.apiBaseUrl as string;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const res = await fetch(`${apiBase}/projects/${route.params.id}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
notFound.value = true;
|
||||||
|
} else {
|
||||||
|
project.value = await res.json();
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit(body: { name: string; description?: string; status: string }) {
|
||||||
|
saving.value = true;
|
||||||
|
const updated = await updateProject(route.params.id as string, body);
|
||||||
|
saving.value = false;
|
||||||
|
if (updated) router.push("/projects");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UPage>
|
||||||
|
<UPageHeader title="Edit Project" description="Update project details">
|
||||||
|
<template #right>
|
||||||
|
<UButton label="Back" icon="i-lucide-chevron-left" color="neutral" variant="outline" to="/projects" />
|
||||||
|
</template>
|
||||||
|
</UPageHeader>
|
||||||
|
|
||||||
|
<UPageBody>
|
||||||
|
<UAlert v-if="notFound" color="warning" variant="soft" icon="i-lucide-alert-triangle" title="Project not found" />
|
||||||
|
|
||||||
|
<UProgress v-else-if="loading" />
|
||||||
|
|
||||||
|
<UCard v-else-if="project">
|
||||||
|
<ProjectForm
|
||||||
|
:name="project.name"
|
||||||
|
:description="project.description ?? undefined"
|
||||||
|
:status="project.status"
|
||||||
|
:saving
|
||||||
|
@submit="handleSubmit"
|
||||||
|
/>
|
||||||
|
</UCard>
|
||||||
|
</UPageBody>
|
||||||
|
</UPage>
|
||||||
|
</template>
|
||||||
117
apps/web/app/pages/projects/index.vue
Normal file
117
apps/web/app/pages/projects/index.vue
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TableColumn } from "@nuxt/ui";
|
||||||
|
import type { Project } from "@workspace/schema";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
title: "Projects",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { projects, loading, error, fetchProjects, deleteProject } = useProjectApi();
|
||||||
|
|
||||||
|
const deleting = ref<string | null>(null);
|
||||||
|
const confirmDelete = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => fetchProjects());
|
||||||
|
|
||||||
|
function statusColor(status: string) {
|
||||||
|
if (status === "active") return "success" as const;
|
||||||
|
if (status === "archived") return "neutral" as const;
|
||||||
|
if (status === "completed") return "info" as const;
|
||||||
|
return "neutral" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAndDelete(id: string) {
|
||||||
|
deleting.value = id;
|
||||||
|
await deleteProject(id);
|
||||||
|
deleting.value = null;
|
||||||
|
confirmDelete.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumn<Project>[] = [
|
||||||
|
{ accessorKey: "name", header: "Name" },
|
||||||
|
{ accessorKey: "status", header: "Status" },
|
||||||
|
{
|
||||||
|
accessorKey: "description",
|
||||||
|
header: "Description",
|
||||||
|
cell: ({ row }) => row.getValue("description") ?? "—",
|
||||||
|
},
|
||||||
|
{ accessorKey: "createdAt", header: "Created" },
|
||||||
|
{ id: "actions", header: "" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UPage>
|
||||||
|
<UPageHeader
|
||||||
|
title="Projects"
|
||||||
|
description="Manage your projects"
|
||||||
|
:links="[{ label: 'New project', icon: 'i-lucide-plus', to: '/projects/new', color: 'primary' }]" />
|
||||||
|
|
||||||
|
<UPageBody>
|
||||||
|
<UAlert v-if="error" color="error" variant="soft" icon="i-lucide-alert-circle" :title="error" class="mb-4" />
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<UTable :columns="columns" :data="projects" :loading="loading" class="flex-1">
|
||||||
|
<template #status-cell="{ row }">
|
||||||
|
<UBadge
|
||||||
|
:label="row.getValue('status')"
|
||||||
|
:color="statusColor(row.getValue('status') as string)"
|
||||||
|
variant="subtle"
|
||||||
|
class="capitalize" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #createdAt-cell="{ row }">
|
||||||
|
<span class="text-muted">{{ new Date(row.getValue("createdAt") as string).toLocaleDateString() }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions-cell="{ row }">
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-pencil"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:to="`/projects/${row.original.id}/edit`"
|
||||||
|
aria-label="Edit" />
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:loading="deleting === row.original.id"
|
||||||
|
aria-label="Delete"
|
||||||
|
@click="
|
||||||
|
confirmDelete = true;
|
||||||
|
deleting = row.original.id;
|
||||||
|
" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<div class="py-12 text-center text-muted">
|
||||||
|
<UIcon name="i-lucide-folder-open" class="size-8 mx-auto mb-2" />
|
||||||
|
<p>No projects yet.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UTable>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UModal v-model:open="confirmDelete">
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-lg font-semibold">Delete project</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<p class="text-sm text-muted">Are you sure you want to delete this project? This action cannot be undone.</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<UButton label="Cancel" color="neutral" variant="outline" @click="confirmDelete = false" />
|
||||||
|
<UButton label="Delete" color="error" @click="deleting && confirmAndDelete(deleting)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</UPageBody>
|
||||||
|
</UPage>
|
||||||
|
</template>
|
||||||
34
apps/web/app/pages/projects/new.vue
Normal file
34
apps/web/app/pages/projects/new.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
title: "New Project",
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { createProject } = useProjectApi();
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
async function handleSubmit(body: { name: string; description?: string; status: string }) {
|
||||||
|
saving.value = true;
|
||||||
|
const project = await createProject(body);
|
||||||
|
saving.value = false;
|
||||||
|
if (project) router.push("/projects");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UPage>
|
||||||
|
<UPageHeader
|
||||||
|
title="New Project"
|
||||||
|
description="Create a new project"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UPageBody>
|
||||||
|
<UCard>
|
||||||
|
<ProjectForm
|
||||||
|
:saving
|
||||||
|
@submit="handleSubmit"
|
||||||
|
/>
|
||||||
|
</UCard>
|
||||||
|
</UPageBody>
|
||||||
|
</UPage>
|
||||||
|
</template>
|
||||||
|
|
@ -6,5 +6,9 @@ export default withNuxt({
|
||||||
rules: {
|
rules: {
|
||||||
...baseRules,
|
...baseRules,
|
||||||
"@stylistic/member-delimiter-style": "off",
|
"@stylistic/member-delimiter-style": "off",
|
||||||
|
"vue/max-attributes-per-line": "off",
|
||||||
|
"vue/singleline-html-element-content-newline": "off",
|
||||||
|
"vue/html-closing-bracket-newline": "off",
|
||||||
|
"vue/no-multiple-template-root": "off",
|
||||||
},
|
},
|
||||||
},)
|
},)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,16 @@
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./constants": {
|
||||||
|
"types": "./src/constants.ts",
|
||||||
|
"import": "./src/constants.ts",
|
||||||
|
"default": "./src/constants.ts"
|
||||||
|
},
|
||||||
|
"./migrations": {
|
||||||
|
"types": "./dist/migrations.d.ts",
|
||||||
|
"import": "./dist/migrations.js",
|
||||||
|
"default": "./dist/migrations.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { defineConfig } from "rolldown";
|
import { defineConfig } from "rolldown";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
input: "src/index.ts",
|
input: ["src/index.ts", "src/migrations.ts"],
|
||||||
output: {
|
output: {
|
||||||
format: "esm",
|
format: "esm",
|
||||||
dir: "dist",
|
dir: "dist",
|
||||||
entryFileNames: "index.js",
|
|
||||||
},
|
},
|
||||||
external: (id: string) =>
|
external: (id: string) =>
|
||||||
id === "drizzle-orm" ||
|
id === "drizzle-orm" ||
|
||||||
|
|
|
||||||
8
packages/schema/src/constants.ts
Normal file
8
packages/schema/src/constants.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export const PROJECT_STATUSES = ["active", "archived", "completed"] as const;
|
||||||
|
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
||||||
|
|
||||||
|
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const;
|
||||||
|
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
||||||
|
|
||||||
|
export const PRIORITIES = ["low", "medium", "high", "urgent"] as const;
|
||||||
|
export type Priority = (typeof PRIORITIES)[number];
|
||||||
|
|
@ -2,9 +2,7 @@ import { pgTable, uuid, text, timestamp, check } from "drizzle-orm/pg-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot";
|
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot";
|
||||||
import { InferOutput, picklist, pipe } from "valibot";
|
import { InferOutput, picklist, pipe } from "valibot";
|
||||||
|
import { PROJECT_STATUSES } from "../constants";
|
||||||
export const PROJECT_STATUSES = ["active", "archived", "completed"] as const;
|
|
||||||
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
|
||||||
|
|
||||||
const projectStatusValues = PROJECT_STATUSES.map((s) => `'${s}'`).join(", ");
|
const projectStatusValues = PROJECT_STATUSES.map((s) => `'${s}'`).join(", ");
|
||||||
const createProjectStatusSchema = (schema: any) => pipe(schema, picklist([...PROJECT_STATUSES])) as any;
|
const createProjectStatusSchema = (schema: any) => pipe(schema, picklist([...PROJECT_STATUSES])) as any;
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,7 @@ import { sql } from "drizzle-orm";
|
||||||
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot";
|
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-valibot";
|
||||||
import { InferOutput, picklist, pipe } from "valibot";
|
import { InferOutput, picklist, pipe } from "valibot";
|
||||||
import { projects } from "./projects";
|
import { projects } from "./projects";
|
||||||
|
import { TASK_STATUSES, PRIORITIES } from "../constants";
|
||||||
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const;
|
|
||||||
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
|
||||||
|
|
||||||
export const PRIORITIES = ["low", "medium", "high", "urgent"] as const;
|
|
||||||
export type Priority = (typeof PRIORITIES)[number];
|
|
||||||
|
|
||||||
const taskStatusValues = TASK_STATUSES.map((s) => `'${s}'`).join(", ");
|
const taskStatusValues = TASK_STATUSES.map((s) => `'${s}'`).join(", ");
|
||||||
const priorityValues = PRIORITIES.map((p) => `'${p}'`).join(", ");
|
const priorityValues = PRIORITIES.map((p) => `'${p}'`).join(", ");
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,30 @@
|
||||||
import { fileURLToPath } from "url";
|
export {
|
||||||
import { dirname, resolve } from "path";
|
PROJECT_STATUSES,
|
||||||
|
type ProjectStatus,
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
TASK_STATUSES,
|
||||||
|
type TaskStatus,
|
||||||
export const migrationsDir = resolve(__dirname, "../drizzle");
|
PRIORITIES,
|
||||||
|
type Priority,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
export { eq } from "drizzle-orm";
|
export { eq } from "drizzle-orm";
|
||||||
export { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres";
|
export { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
export { migrate } from "drizzle-orm/node-postgres/migrator";
|
export { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
PROJECT_STATUSES,
|
|
||||||
projects,
|
projects,
|
||||||
projectSelect,
|
projectSelect,
|
||||||
projectInsert,
|
projectInsert,
|
||||||
projectUpdate,
|
projectUpdate,
|
||||||
type Project,
|
type Project,
|
||||||
type ProjectStatus,
|
|
||||||
type ProjectInsertDTO,
|
type ProjectInsertDTO,
|
||||||
type ProjectUpdateDTO,
|
type ProjectUpdateDTO,
|
||||||
} from "./drizzle/projects";
|
} from "./drizzle/projects";
|
||||||
export { projectsRelations, tasksRelations } from "./drizzle/relations";
|
export { projectsRelations, tasksRelations } from "./drizzle/relations";
|
||||||
export {
|
export {
|
||||||
TASK_STATUSES,
|
|
||||||
PRIORITIES,
|
|
||||||
tasks,
|
tasks,
|
||||||
taskSelect,
|
taskSelect,
|
||||||
taskInsert,
|
taskInsert,
|
||||||
taskUpdate,
|
taskUpdate,
|
||||||
type Task,
|
type Task,
|
||||||
type TaskStatus,
|
|
||||||
type Priority,
|
|
||||||
} from "./drizzle/tasks";
|
} from "./drizzle/tasks";
|
||||||
|
|
|
||||||
6
packages/schema/src/migrations.ts
Normal file
6
packages/schema/src/migrations.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, resolve } from "path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export const migrationsDir = resolve(__dirname, "../drizzle");
|
||||||
Loading…
Reference in a new issue