Neo platform - First Version

This commit is contained in:
Francisco Gaona
2025-11-25 12:21:14 +01:00
commit 484af68571
59 changed files with 3699 additions and 0 deletions

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine AS base
WORKDIR /usr/src/app
FROM base AS deps
COPY package*.json ./
RUN npm install
FROM base AS dev
ENV NODE_ENV=development
COPY --from=deps /usr/src/app/node_modules ./node_modules
COPY . .
EXPOSE 3001
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3001"]

9
frontend/app.vue Normal file
View File

@@ -0,0 +1,9 @@
<template>
<div>
<NuxtPage />
</div>
</template>
<style>
@import 'assets/css/main.css';
</style>

View File

@@ -0,0 +1,55 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,66 @@
export const useApi = () => {
const config = useRuntimeConfig()
const apiBaseUrl = config.public.apiBaseUrl
const getHeaders = () => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// Add tenant ID from localStorage or state
if (process.client) {
const tenantId = localStorage.getItem('tenantId')
if (tenantId) {
headers['x-tenant-id'] = tenantId
}
const token = localStorage.getItem('token')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
}
return headers
}
const api = {
async get(path: string) {
const response = await fetch(`${apiBaseUrl}/api${path}`, {
headers: getHeaders(),
})
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
return response.json()
},
async post(path: string, data: any) {
const response = await fetch(`${apiBaseUrl}/api${path}`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data),
})
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
return response.json()
},
async put(path: string, data: any) {
const response = await fetch(`${apiBaseUrl}/api${path}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data),
})
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
return response.json()
},
async delete(path: string) {
const response = await fetch(`${apiBaseUrl}/api${path}`, {
method: 'DELETE',
headers: getHeaders(),
})
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
return response.json()
},
}
return { api }
}

32
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,32 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: true },
modules: ['@nuxtjs/tailwindcss', '@nuxtjs/color-mode'],
colorMode: {
classSuffix: '',
},
runtimeConfig: {
public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
},
},
app: {
head: {
title: 'Neo Platform',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
},
},
typescript: {
strict: true,
},
compatibilityDate: '2024-01-01',
})

33
frontend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "neo-frontend",
"version": "0.0.1",
"description": "Multi-tenant Nova/Salesforce-like platform frontend",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxtjs/tailwindcss": "^6.11.4",
"@vueuse/core": "^10.7.2",
"nuxt": "^3.10.0",
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"radix-vue": "^1.4.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.1",
"lucide-vue-next": "^0.309.0"
},
"devDependencies": {
"@nuxtjs/color-mode": "^3.3.2",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,87 @@
<template>
<div class="min-h-screen bg-background">
<header class="border-b">
<div class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<NuxtLink to="/" class="text-xl font-bold">Neo Platform</NuxtLink>
<nav class="flex gap-4">
<NuxtLink
v-for="page in pages"
:key="page.id"
:to="`/app/${appSlug}/${page.slug}`"
class="text-sm hover:text-primary"
>
{{ page.label }}
</NuxtLink>
</nav>
</div>
</div>
</header>
<main class="container mx-auto px-4 py-8">
<div v-if="loading" class="text-center py-12">Loading...</div>
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
<div v-else-if="page">
<h1 class="text-3xl font-bold mb-6">{{ page.label }}</h1>
<p class="text-muted-foreground mb-4">Page Type: {{ page.type }}</p>
<div v-if="page.objectApiName">
<h2 class="text-xl font-semibold mb-4">{{ page.object?.label }} Records</h2>
<div v-if="loadingRecords" class="text-center py-8">Loading records...</div>
<div v-else-if="records.length === 0" class="text-center py-8 text-muted-foreground">
No records found
</div>
<div v-else class="space-y-2">
<NuxtLink
v-for="record in records"
:key="record.id"
:to="`/app/${appSlug}/${pageSlug}/${record.id}`"
class="block p-4 border rounded-lg hover:border-primary transition-colors bg-card"
>
<div class="font-medium">{{ record.name || record.id }}</div>
</NuxtLink>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const { api } = useApi()
const appSlug = computed(() => route.params.appSlug as string)
const pageSlug = computed(() => route.params.pageSlug as string)
const pages = ref([])
const page = ref(null)
const records = ref([])
const loading = ref(true)
const loadingRecords = ref(false)
const error = ref(null)
const fetchPage = async () => {
try {
loading.value = true
page.value = await api.get(`/runtime/apps/${appSlug.value}/pages/${pageSlug.value}`)
const app = await api.get(`/runtime/apps/${appSlug.value}`)
pages.value = app.pages
if (page.value.objectApiName) {
loadingRecords.value = true
records.value = await api.get(`/runtime/objects/${page.value.objectApiName}/records`)
}
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
loadingRecords.value = false
}
}
onMounted(() => {
fetchPage()
})
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="min-h-screen bg-background">
<header class="border-b">
<div class="container mx-auto px-4 py-4">
<NuxtLink to="/" class="text-xl font-bold">Neo Platform</NuxtLink>
</div>
</header>
<main class="container mx-auto px-4 py-8">
<div v-if="loading" class="text-center py-12">Loading...</div>
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
<div v-else-if="record">
<div class="mb-6">
<NuxtLink
:to="`/app/${appSlug}/${pageSlug}`"
class="text-sm text-primary hover:underline"
>
Back to List
</NuxtLink>
</div>
<h1 class="text-3xl font-bold mb-6">{{ record.name || 'Record Detail' }}</h1>
<div class="space-y-4">
<div v-for="(value, key) in record" :key="key" class="border-b pb-2">
<div class="text-sm text-muted-foreground">{{ key }}</div>
<div class="font-medium">{{ value }}</div>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const { api } = useApi()
const appSlug = computed(() => route.params.appSlug as string)
const pageSlug = computed(() => route.params.pageSlug as string)
const recordId = computed(() => route.params.recordId as string)
const record = ref(null)
const loading = ref(true)
const error = ref(null)
const objectApiName = ref('')
const fetchRecord = async () => {
try {
loading.value = true
// First get page metadata to know which object this is
const page = await api.get(`/runtime/apps/${appSlug.value}/pages/${pageSlug.value}`)
objectApiName.value = page.objectApiName
// Then fetch the record
record.value = await api.get(`/runtime/objects/${objectApiName.value}/records/${recordId.value}`)
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(() => {
fetchRecord()
})
</script>

31
frontend/pages/index.vue Normal file
View File

@@ -0,0 +1,31 @@
<template>
<div class="min-h-screen bg-background">
<header class="border-b">
<div class="container mx-auto px-4 py-4">
<h1 class="text-2xl font-bold">Neo Platform</h1>
</div>
</header>
<main class="container mx-auto px-4 py-8">
<div class="text-center space-y-6">
<h2 class="text-4xl font-bold">Welcome to Neo Platform</h2>
<p class="text-muted-foreground text-lg">
Multi-tenant application platform for building CRM, Project Management, and more
</p>
<div class="flex gap-4 justify-center">
<NuxtLink
to="/setup/apps"
class="px-6 py-3 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Setup Apps
</NuxtLink>
<NuxtLink
to="/setup/objects"
class="px-6 py-3 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
>
Setup Objects
</NuxtLink>
</div>
</div>
</main>
</div>
</template>

110
frontend/pages/login.vue Normal file
View File

@@ -0,0 +1,110 @@
<template>
<div class="min-h-screen bg-background flex items-center justify-center">
<div class="w-full max-w-md p-8 border rounded-lg bg-card">
<h1 class="text-3xl font-bold mb-6 text-center">Login</h1>
<div v-if="error" class="mb-4 p-3 bg-destructive/10 text-destructive rounded">
{{ error }}
</div>
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Tenant ID</label>
<input
v-model="tenantId"
type="text"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="123"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Email</label>
<input
v-model="email"
type="email"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="user@example.com"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Password</label>
<input
v-model="password"
type="password"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="••••••••"
/>
</div>
<button
type="submit"
:disabled="loading"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{{ loading ? 'Logging in...' : 'Login' }}
</button>
</form>
<div class="mt-4 text-center text-sm text-muted-foreground">
Don't have an account?
<NuxtLink to="/register" class="text-primary hover:underline">
Register
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const config = useRuntimeConfig()
const router = useRouter()
const tenantId = ref('123')
const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
const handleLogin = async () => {
try {
loading.value = true
error.value = ''
const response = await fetch(`${config.public.apiBaseUrl}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-tenant-id': tenantId.value,
},
body: JSON.stringify({
email: email.value,
password: password.value,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.message || 'Login failed')
}
const data = await response.json()
// Store credentials
localStorage.setItem('tenantId', tenantId.value)
localStorage.setItem('token', data.access_token)
localStorage.setItem('user', JSON.stringify(data.user))
// Redirect to home
router.push('/')
} catch (e: any) {
error.value = e.message || 'Login failed'
} finally {
loading.value = false
}
}
</script>

138
frontend/pages/register.vue Normal file
View File

@@ -0,0 +1,138 @@
<template>
<div class="min-h-screen bg-background flex items-center justify-center">
<div class="w-full max-w-md p-8 border rounded-lg bg-card">
<h1 class="text-3xl font-bold mb-6 text-center">Register</h1>
<div v-if="error" class="mb-4 p-3 bg-destructive/10 text-destructive rounded">
{{ error }}
</div>
<div v-if="success" class="mb-4 p-3 bg-green-500/10 text-green-600 rounded">
Registration successful! Redirecting to login...
</div>
<form @submit.prevent="handleRegister" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Tenant ID</label>
<input
v-model="tenantId"
type="text"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="123"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Email</label>
<input
v-model="email"
type="email"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="user@example.com"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Password</label>
<input
v-model="password"
type="password"
required
minlength="6"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="••••••••"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">First Name (Optional)</label>
<input
v-model="firstName"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="John"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Last Name (Optional)</label>
<input
v-model="lastName"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="Doe"
/>
</div>
<button
type="submit"
:disabled="loading"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{{ loading ? 'Registering...' : 'Register' }}
</button>
</form>
<div class="mt-4 text-center text-sm text-muted-foreground">
Already have an account?
<NuxtLink to="/login" class="text-primary hover:underline">
Login
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const config = useRuntimeConfig()
const router = useRouter()
const tenantId = ref('123')
const email = ref('')
const password = ref('')
const firstName = ref('')
const lastName = ref('')
const loading = ref(false)
const error = ref('')
const success = ref(false)
const handleRegister = async () => {
try {
loading.value = true
error.value = ''
success.value = false
const response = await fetch(`${config.public.apiBaseUrl}/api/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-tenant-id': tenantId.value,
},
body: JSON.stringify({
email: email.value,
password: password.value,
firstName: firstName.value || undefined,
lastName: lastName.value || undefined,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.message || 'Registration failed')
}
success.value = true
// Redirect to login after 2 seconds
setTimeout(() => {
router.push('/login')
}, 2000)
} catch (e: any) {
error.value = e.message || 'Registration failed'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="min-h-screen bg-background">
<header class="border-b">
<div class="container mx-auto px-4 py-4">
<NuxtLink to="/" class="text-xl font-bold">Neo Platform</NuxtLink>
</div>
</header>
<main class="container mx-auto px-4 py-8">
<div v-if="loading" class="text-center py-12">Loading...</div>
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
<div v-else-if="app">
<div class="mb-6">
<NuxtLink to="/setup/apps" class="text-sm text-primary hover:underline">
Back to Apps
</NuxtLink>
</div>
<h1 class="text-3xl font-bold mb-6">{{ app.label }}</h1>
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">Pages</h2>
<div class="space-y-2">
<div
v-for="page in app.pages"
:key="page.id"
class="p-4 border rounded-lg bg-card"
>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold">{{ page.label }}</h3>
<p class="text-sm text-muted-foreground">
Type: {{ page.type }} | Slug: {{ page.slug }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const { api } = useApi()
const app = ref(null)
const loading = ref(true)
const error = ref(null)
const fetchApp = async () => {
try {
loading.value = true
const slug = route.params.slug as string
app.value = await api.get(`/setup/apps/${slug}`)
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(() => {
fetchApp()
})
</script>

View File

@@ -0,0 +1,144 @@
<template>
<div class="min-h-screen bg-background">
<header class="border-b">
<div class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<NuxtLink to="/" class="text-xl font-bold">Neo Platform</NuxtLink>
<div class="flex gap-4">
<NuxtLink
to="/setup/apps"
class="text-sm text-muted-foreground hover:text-foreground"
>
Apps
</NuxtLink>
<NuxtLink
to="/setup/objects"
class="text-sm text-muted-foreground hover:text-foreground"
>
Objects
</NuxtLink>
</div>
</div>
</div>
</header>
<main class="container mx-auto px-4 py-8">
<div v-if="loading" class="text-center py-12">Loading...</div>
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
<div v-else>
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Applications</h1>
<button
@click="showCreateForm = true"
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
New App
</button>
</div>
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
<h2 class="text-xl font-semibold mb-4">Create New App</h2>
<form @submit.prevent="createApp" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Slug</label>
<input
v-model="newApp.slug"
type="text"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="my-app"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Label</label>
<input
v-model="newApp.label"
type="text"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="My App"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Description</label>
<textarea
v-model="newApp.description"
class="w-full px-3 py-2 border rounded-md bg-background"
rows="3"
/>
</div>
<div class="flex gap-2">
<button
type="submit"
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Create
</button>
<button
type="button"
@click="showCreateForm = false"
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
>
Cancel
</button>
</div>
</form>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="app in apps"
:key="app.id"
:to="`/setup/apps/${app.slug}`"
class="p-6 border rounded-lg hover:border-primary transition-colors bg-card"
>
<h3 class="text-xl font-semibold mb-2">{{ app.label }}</h3>
<p class="text-sm text-muted-foreground mb-4">{{ app.description || 'No description' }}</p>
<div class="text-sm">
<span class="text-muted-foreground">{{ app.pages?.length || 0 }} pages</span>
</div>
</NuxtLink>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
const { api } = useApi()
const apps = ref([])
const loading = ref(true)
const error = ref(null)
const showCreateForm = ref(false)
const newApp = ref({
slug: '',
label: '',
description: '',
})
const fetchApps = async () => {
try {
loading.value = true
apps.value = await api.get('/setup/apps')
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
const createApp = async () => {
try {
await api.post('/setup/apps', newApp.value)
showCreateForm.value = false
newApp.value = { slug: '', label: '', description: '' }
await fetchApps()
} catch (e: any) {
alert('Error creating app: ' + e.message)
}
}
onMounted(() => {
fetchApps()
})
</script>

View File

@@ -0,0 +1,75 @@
<template>
<div class="min-h-screen bg-background">
<header class="border-b">
<div class="container mx-auto px-4 py-4">
<NuxtLink to="/" class="text-xl font-bold">Neo Platform</NuxtLink>
</div>
</header>
<main class="container mx-auto px-4 py-8">
<div v-if="loading" class="text-center py-12">Loading...</div>
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
<div v-else-if="object">
<div class="mb-6">
<NuxtLink to="/setup/objects" class="text-sm text-primary hover:underline">
Back to Objects
</NuxtLink>
</div>
<h1 class="text-3xl font-bold mb-6">{{ object.label }}</h1>
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">Fields</h2>
<div class="space-y-2">
<div
v-for="field in object.fields"
:key="field.id"
class="p-4 border rounded-lg bg-card"
>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold">{{ field.label }}</h3>
<p class="text-sm text-muted-foreground">
Type: {{ field.type }} | API Name: {{ field.apiName }}
</p>
</div>
<div class="flex gap-2 text-xs">
<span v-if="field.isRequired" class="px-2 py-1 bg-destructive/10 text-destructive rounded">
Required
</span>
<span v-if="field.isUnique" class="px-2 py-1 bg-primary/10 text-primary rounded">
Unique
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const { api } = useApi()
const object = ref(null)
const loading = ref(true)
const error = ref(null)
const fetchObject = async () => {
try {
loading.value = true
const apiName = route.params.apiName as string
object.value = await api.get(`/setup/objects/${apiName}`)
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(() => {
fetchObject()
})
</script>

View File

@@ -0,0 +1,162 @@
<template>
<div class="min-h-screen bg-background">
<header class="border-b">
<div class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<NuxtLink to="/" class="text-xl font-bold">Neo Platform</NuxtLink>
<div class="flex gap-4">
<NuxtLink
to="/setup/apps"
class="text-sm text-muted-foreground hover:text-foreground"
>
Apps
</NuxtLink>
<NuxtLink
to="/setup/objects"
class="text-sm text-muted-foreground hover:text-foreground"
>
Objects
</NuxtLink>
</div>
</div>
</div>
</header>
<main class="container mx-auto px-4 py-8">
<div v-if="loading" class="text-center py-12">Loading...</div>
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
<div v-else>
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Objects</h1>
<button
@click="showCreateForm = true"
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
New Object
</button>
</div>
<div v-if="showCreateForm" class="mb-6 p-6 border rounded-lg bg-card">
<h2 class="text-xl font-semibold mb-4">Create New Object</h2>
<form @submit.prevent="createObject" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">API Name</label>
<input
v-model="newObject.apiName"
type="text"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="MyObject"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Label</label>
<input
v-model="newObject.label"
type="text"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="My Object"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Plural Label</label>
<input
v-model="newObject.pluralLabel"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="My Objects"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Description</label>
<textarea
v-model="newObject.description"
class="w-full px-3 py-2 border rounded-md bg-background"
rows="3"
/>
</div>
<div class="flex gap-2">
<button
type="submit"
class="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Create
</button>
<button
type="button"
@click="showCreateForm = false"
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
>
Cancel
</button>
</div>
</form>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="obj in objects"
:key="obj.id"
:to="`/setup/objects/${obj.apiName}`"
class="p-6 border rounded-lg hover:border-primary transition-colors bg-card"
>
<div class="flex items-start justify-between mb-2">
<h3 class="text-xl font-semibold">{{ obj.label }}</h3>
<span
v-if="obj.isSystem"
class="text-xs px-2 py-1 bg-muted text-muted-foreground rounded"
>
System
</span>
</div>
<p class="text-sm text-muted-foreground mb-4">{{ obj.description || 'No description' }}</p>
<div class="text-sm">
<span class="text-muted-foreground">{{ obj.fields?.length || 0 }} fields</span>
</div>
</NuxtLink>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
const { api } = useApi()
const objects = ref([])
const loading = ref(true)
const error = ref(null)
const showCreateForm = ref(false)
const newObject = ref({
apiName: '',
label: '',
pluralLabel: '',
description: '',
})
const fetchObjects = async () => {
try {
loading.value = true
objects.value = await api.get('/setup/objects')
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
const createObject = async () => {
try {
await api.post('/setup/objects', newObject.value)
showCreateForm.value = false
newObject.value = { apiName: '', label: '', pluralLabel: '', description: '' }
await fetchObjects()
} catch (e: any) {
alert('Error creating object: ' + e.message)
}
}
onMounted(() => {
fetchObjects()
})
</script>

View File

@@ -0,0 +1,52 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./components/**/*.{js,vue,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
'./plugins/**/*.{js,ts}',
'./app.vue',
],
darkMode: 'class',
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
}

16
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node"]
},
"extends": "./.nuxt/tsconfig.json"
}