feat: split admin into dedicated web surface
This commit is contained in:
+4
-2
@@ -1,10 +1,12 @@
|
||||
# App runtime
|
||||
NODE_ENV="development"
|
||||
|
||||
# Frontend env vars for the Vite app
|
||||
# Frontend env vars for the Vite apps
|
||||
# Use "/api" for same-origin nginx proxy setups (app/admin subdomain or docker web surfaces).
|
||||
VITE_API_BASE_URL="http://localhost:4000/api"
|
||||
VITE_GOOGLE_MAPS_PLATFORM_KEY="YOUR_BROWSER_MAPS_KEY"
|
||||
WEB_PORT="3000"
|
||||
ADMIN_WEB_PORT="3001"
|
||||
|
||||
# Backend env vars
|
||||
## For Docker Compose deployments, point DATABASE_URL at the internal "db" host.
|
||||
@@ -13,7 +15,7 @@ DATABASE_URL="postgres://postgres:postgres@localhost:5432/leads4less"
|
||||
COOKIE_SECRET="CHANGE_ME_IN_LOCAL_ENV"
|
||||
APP_HOST="0.0.0.0"
|
||||
APP_PORT="4000"
|
||||
APP_ORIGIN="http://localhost:3000"
|
||||
APP_ORIGIN="http://localhost:3000,http://localhost:3001"
|
||||
SESSION_TTL_DAYS="30"
|
||||
GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY"
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
dist-admin/
|
||||
dist-server/
|
||||
coverage/
|
||||
.DS_Store
|
||||
|
||||
+5
-1
@@ -11,12 +11,16 @@ COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build && npm run build:api
|
||||
RUN npm run build && npm run build:admin && npm run build:api
|
||||
|
||||
FROM nginx:alpine AS web
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
FROM nginx:alpine AS web-admin
|
||||
COPY --from=build /app/dist-admin /usr/share/nginx/html
|
||||
COPY nginx.admin.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
FROM node:22-alpine AS runtime-base
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -19,8 +19,10 @@ LocaleScope is a React + Vite app for researching local markets, saving business
|
||||
- `COOKIE_SECRET`
|
||||
- `GOOGLE_MAPS_SERVER_KEY`
|
||||
- Stripe env vars below if you want to test billing locally
|
||||
3. Run the frontend:
|
||||
3. Run the user frontend:
|
||||
`npm run dev:web`
|
||||
4. Run the admin frontend (dedicated surface):
|
||||
`npm run dev:admin`
|
||||
|
||||
The local backend and scripts load both `.env` and `.env.local`, with `.env.local` taking precedence, so you can keep the full local setup in one file during development.
|
||||
|
||||
@@ -42,8 +44,8 @@ If you open the app from another machine on your LAN, set `VITE_API_BASE_URL` an
|
||||
- [ ] Copy `.env.example` to `.env.local` and fill required keys: `DATABASE_URL`, `COOKIE_SECRET`, `GOOGLE_MAPS_SERVER_KEY`, `VITE_GOOGLE_MAPS_PLATFORM_KEY`, and Stripe keys if testing billing
|
||||
- [ ] Run migrations: `npm run migrate`
|
||||
- [ ] Set `ALLOW_ADMIN_BOOTSTRAP=true` and define `ADMIN_BOOTSTRAP_TOKEN`
|
||||
- [ ] Start web, API, and worker: `npm run dev:web`, `npm run dev:api`, `npm run dev:worker`
|
||||
- [ ] Visit `/auth` and create the first site admin
|
||||
- [ ] Start app web, admin web, API, and worker: `npm run dev:web`, `npm run dev:admin`, `npm run dev:api`, `npm run dev:worker`
|
||||
- [ ] Visit the admin surface and create the first site admin
|
||||
- [ ] Disable bootstrap after first admin creation: `ALLOW_ADMIN_BOOTSTRAP=false`
|
||||
- [ ] Verify admin billing access at `/api/admin/billing/workspaces`
|
||||
|
||||
@@ -56,7 +58,7 @@ Bootstrap mode is only needed when no active application admin exists.
|
||||
|
||||
1. Run migrations first: `npm run migrate`
|
||||
2. Set `ALLOW_ADMIN_BOOTSTRAP=true` and `ADMIN_BOOTSTRAP_TOKEN` in your env file
|
||||
3. Visit `/auth`, then create the first account in "Create first site admin" mode
|
||||
3. Visit the dedicated admin surface, then create the first account in "Create first site admin" mode
|
||||
4. After the first admin is created, set `ALLOW_ADMIN_BOOTSTRAP=false`
|
||||
|
||||
## Stripe Billing Setup
|
||||
@@ -127,18 +129,26 @@ Safe mutation pilot:
|
||||
- `POSTGRES_USER`
|
||||
- `POSTGRES_PASSWORD`
|
||||
- `WEB_PORT`
|
||||
- `ADMIN_WEB_PORT`
|
||||
- `APP_PORT`
|
||||
- `COOKIE_SECRET`
|
||||
- `GOOGLE_MAPS_SERVER_KEY`
|
||||
- `VITE_API_BASE_URL` (use `/api` when web and API share a domain via the nginx proxy)
|
||||
- `VITE_API_BASE_URL` (use `/api` when web/admin and API share a domain via the nginx proxy)
|
||||
- `VITE_GOOGLE_MAPS_PLATFORM_KEY`
|
||||
2. Create the shared Docker network if it does not exist:
|
||||
`docker network create locale-all || true`
|
||||
3. Build and start the full stack:
|
||||
`docker compose up --build`
|
||||
|
||||
The Compose stack starts PostGIS, waits for the database to become healthy, runs migrations automatically, and then starts the API, worker, and web containers.
|
||||
With the default `.env.example` values, the app is exposed on `http://localhost:3000` and the API on `http://localhost:4000`.
|
||||
The Compose stack starts PostGIS, waits for the database to become healthy, runs migrations automatically, and then starts the API, worker, user-web, and admin-web containers.
|
||||
With the default `.env.example` values, the user app is exposed on `http://localhost:3000`, the admin app on `http://localhost:3001`, and the API on `http://localhost:4000`.
|
||||
|
||||
## App and Admin Surfaces
|
||||
|
||||
- User and admin UIs now run on separate frontend surfaces/build targets.
|
||||
- The main app no longer links to or renders admin routes/tabs.
|
||||
- Admin tasks live on the dedicated admin surface and continue using the same API/session auth.
|
||||
- `APP_ORIGIN` should include both origins for CORS/session checks, for example: `https://localescope.com,https://admin.localescope.com`.
|
||||
|
||||
## Database Layout
|
||||
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Leads4less Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/admin-main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -98,6 +98,22 @@ services:
|
||||
ports:
|
||||
- "${WEB_PORT}:80"
|
||||
|
||||
admin-web:
|
||||
build:
|
||||
context: .
|
||||
target: web-admin
|
||||
args:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
||||
VITE_GOOGLE_MAPS_PLATFORM_KEY: ${VITE_GOOGLE_MAPS_PLATFORM_KEY}
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- locale-all
|
||||
ports:
|
||||
- "${ADMIN_WEB_PORT}:80"
|
||||
|
||||
volumes:
|
||||
leads4less-db:
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:4000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -6,12 +6,14 @@
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
"dev:web": "vite --port=3000 --host=0.0.0.0",
|
||||
"dev:admin": "vite --config vite.admin.config.ts --port=3001 --host=0.0.0.0",
|
||||
"dev:api": "tsx --tsconfig tsconfig.server.json server/src/index.ts",
|
||||
"dev:worker": "tsx --tsconfig tsconfig.server.json server/src/worker.ts",
|
||||
"build": "vite build",
|
||||
"build:admin": "vite build --config vite.admin.config.ts",
|
||||
"build:api": "tsc -p tsconfig.server.json",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist dist-server",
|
||||
"clean": "rm -rf dist dist-admin dist-server",
|
||||
"lint": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit",
|
||||
"migrate": "tsx --tsconfig tsconfig.server.json db/scripts/migrate.ts",
|
||||
"import:postal": "tsx --tsconfig tsconfig.server.json db/scripts/import-postal-areas.ts",
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { ShieldAlert, ShieldCheck } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AdminBootstrapStatusResponse, SessionUser } from '../shared/types';
|
||||
import { AdminPage } from './components/AdminPage';
|
||||
import { Alert, Button, Card, FieldLabel, Input } from './components/ui';
|
||||
import {
|
||||
claimAdminBootstrap,
|
||||
getAdminBootstrapStatus,
|
||||
getLocalSessionUser,
|
||||
signInWithLocalAuth,
|
||||
signOutWithLocalAuth,
|
||||
} from './lib/auth';
|
||||
import { hasApiConfig } from './lib/api';
|
||||
|
||||
export function AdminPortal() {
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [bootstrapToken, setBootstrapToken] = useState('');
|
||||
const [bootstrapStatus, setBootstrapStatus] = useState<AdminBootstrapStatusResponse | null>(null);
|
||||
const [bootstrapLoading, setBootstrapLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadSession = async () => {
|
||||
try {
|
||||
const sessionUser = await getLocalSessionUser();
|
||||
if (isMounted) {
|
||||
setUser(sessionUser);
|
||||
}
|
||||
} catch (nextError) {
|
||||
if (isMounted) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load session.');
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadSession();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadBootstrapStatus = async () => {
|
||||
setBootstrapLoading(true);
|
||||
try {
|
||||
const status = await getAdminBootstrapStatus();
|
||||
if (isMounted) {
|
||||
setBootstrapStatus(status);
|
||||
}
|
||||
} catch (nextError) {
|
||||
if (isMounted) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to load bootstrap status.');
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setBootstrapLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadBootstrapStatus();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const isBootstrapRequired = bootstrapStatus?.bootstrapRequired === true;
|
||||
|
||||
const handleSignIn = async () => {
|
||||
if (isBootstrapRequired && bootstrapLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (isBootstrapRequired) {
|
||||
const nextUser = await claimAdminBootstrap({
|
||||
email,
|
||||
password,
|
||||
displayName: displayName.trim() || undefined,
|
||||
bootstrapToken,
|
||||
});
|
||||
setUser(nextUser);
|
||||
setNotice('First site admin created and signed in.');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUser = await signInWithLocalAuth({ email, password });
|
||||
setUser(nextUser);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Authentication failed.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
const sessionId = user?.sessionId;
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setUser(null);
|
||||
|
||||
try {
|
||||
await signOutWithLocalAuth(sessionId);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : 'Failed to sign out.');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <FullscreenCard title="Loading admin portal..." />;
|
||||
}
|
||||
|
||||
if (!hasApiConfig) {
|
||||
return (
|
||||
<FullscreenCard title="Local API config required">
|
||||
<p className="text-sm text-stone-600">Set VITE_API_BASE_URL and restart the admin frontend.</p>
|
||||
</FullscreenCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-stone-50 p-4">
|
||||
<Card className="w-full max-w-md p-6">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="rounded-xl bg-stone-900 p-2 text-white">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-stone-900">LocaleScope Admin</h1>
|
||||
<p className="text-sm text-stone-500">Sign in to access admin operations.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice ? <Alert variant="success">{notice}</Alert> : null}
|
||||
{error ? <Alert variant="error" className="mt-3">{error}</Alert> : null}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input id="admin-email" type="email" autoComplete="email" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Password</FieldLabel>
|
||||
<Input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
autoComplete={isBootstrapRequired ? 'new-password' : 'current-password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{isBootstrapRequired ? (
|
||||
<>
|
||||
<div>
|
||||
<FieldLabel>Display name</FieldLabel>
|
||||
<Input
|
||||
id="admin-display-name"
|
||||
autoComplete="name"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Bootstrap token</FieldLabel>
|
||||
<Input
|
||||
id="admin-bootstrap-token"
|
||||
type="password"
|
||||
value={bootstrapToken}
|
||||
onChange={(event) => setBootstrapToken(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{isBootstrapRequired && !bootstrapLoading ? (
|
||||
<Alert variant="info">Bootstrap is required because no active app admin exists.</Alert>
|
||||
) : null}
|
||||
<Button type="button" className="w-full" disabled={isSubmitting || bootstrapLoading} onClick={() => void handleSignIn()}>
|
||||
{isSubmitting ? (isBootstrapRequired ? 'Creating admin...' : 'Signing in...') : isBootstrapRequired ? 'Create first site admin' : 'Sign in'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.isAdmin) {
|
||||
return (
|
||||
<FullscreenCard title="Access denied">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-stone-600">Your account does not have admin access.</p>
|
||||
{error ? <Alert variant="error">{error}</Alert> : null}
|
||||
<Button type="button" variant="secondary" onClick={() => void handleSignOut()}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</FullscreenCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<header className="border-b border-stone-200 bg-white px-4 py-3 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-7xl items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">LocaleScope</p>
|
||||
<p className="text-lg font-semibold text-stone-900">Admin Portal</p>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={() => void handleSignOut()}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<AdminPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenCard({ title, children }: { title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-stone-50 p-4">
|
||||
<Card className="w-full max-w-xl p-6">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="rounded-xl bg-stone-900 p-2 text-white">
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold text-stone-900">{title}</h1>
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Layout, type AppTab } from './components/Layout';
|
||||
import { AccountPage } from './components/AccountPage';
|
||||
import { AdminPage } from './components/AdminPage';
|
||||
import { Dashboard } from './components/Dashboard';
|
||||
import { MapView } from './components/MapView';
|
||||
import { PricingCards } from './components/PricingCards';
|
||||
@@ -109,12 +108,6 @@ export default function App() {
|
||||
|
||||
const isBootstrapRequired = bootstrapStatus?.bootstrapRequired === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'admin' && !user?.isAdmin) {
|
||||
setActiveTab('account');
|
||||
}
|
||||
}, [activeTab, user]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
@@ -372,7 +365,6 @@ export default function App() {
|
||||
onConsumeInitialCheckoutPlanCode={() => setBillingIntentPlanCode(null)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'admin' && user.isAdmin && <AdminPage />}
|
||||
</Layout>
|
||||
</APIProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { AdminPortal } from './AdminPortal';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AdminPortal />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound, ShieldCheck } from 'lucide-react';
|
||||
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound } from 'lucide-react';
|
||||
import type { SessionUser } from '../../shared/types';
|
||||
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
|
||||
import { cn } from '../lib/cn';
|
||||
import { Button } from './ui';
|
||||
|
||||
export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account' | 'admin';
|
||||
export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account';
|
||||
|
||||
interface LayoutProps {
|
||||
user: SessionUser;
|
||||
@@ -25,7 +25,6 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
|
||||
{ id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'map', name: 'Map View', icon: MapIcon },
|
||||
{ id: 'account', name: 'Account', icon: UserRound },
|
||||
...(user.isAdmin ? ([{ id: 'admin', name: 'Admin', icon: ShieldCheck }] as const) : []),
|
||||
] as const;
|
||||
|
||||
const activeNavigationItem = navigation.find((item) => item.id === activeTab) ?? navigation[0];
|
||||
@@ -109,7 +108,7 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
|
||||
<main className="relative flex min-h-0 flex-1 flex-col overflow-hidden pb-16 lg:pb-0">{children}</main>
|
||||
|
||||
<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-stone-200 bg-white/95 px-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.5rem)] pt-2 backdrop-blur lg:hidden">
|
||||
<div className={cn('grid gap-1', user.isAdmin ? 'grid-cols-6' : 'grid-cols-5')}>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = activeTab === item.id;
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
allowedHosts: ['project-1.duramente.com'],
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
port: 3001,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-admin',
|
||||
rollupOptions: {
|
||||
input: 'admin.html',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user