initial commit
This commit is contained in:
+134
@@ -0,0 +1,134 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
.DS_Store
|
||||
next-env.d.ts
|
||||
/app/generated/prisma
|
||||
@@ -0,0 +1,2 @@
|
||||
.next
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,35 @@
|
||||
/** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */
|
||||
const config = {
|
||||
printWidth: 100,
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
plugins: ['@ianvs/prettier-plugin-sort-imports'],
|
||||
importOrder: [
|
||||
'.*styles.css$',
|
||||
'',
|
||||
'dayjs',
|
||||
'^react$',
|
||||
'^next$',
|
||||
'^next/.*$',
|
||||
'<BUILTIN_MODULES>',
|
||||
'<THIRD_PARTY_MODULES>',
|
||||
'^@mantine/(.*)$',
|
||||
'^@mantinex/(.*)$',
|
||||
'^@mantine-tests/(.*)$',
|
||||
'^@docs/(.*)$',
|
||||
'^@/.*$',
|
||||
'^../(?!.*.css$).*$',
|
||||
'^./(?!.*.css$).*$',
|
||||
'\\.css$',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: '*.mdx',
|
||||
options: {
|
||||
printWidth: 70,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { StorybookConfig } from '@storybook/nextjs';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
core: {
|
||||
disableWhatsNewNotifications: true,
|
||||
disableTelemetry: true,
|
||||
enableCrashReports: false,
|
||||
},
|
||||
stories: ['../components/**/*.(stories|story).@(js|jsx|ts|tsx)'],
|
||||
addons: ['@storybook/addon-themes'],
|
||||
framework: {
|
||||
name: '@storybook/nextjs',
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,41 @@
|
||||
import '@mantine/core/styles.css';
|
||||
|
||||
import { ColorSchemeScript, MantineProvider } from '@mantine/core';
|
||||
import { theme } from '../theme';
|
||||
|
||||
export const parameters = {
|
||||
layout: 'fullscreen',
|
||||
options: {
|
||||
showPanel: false,
|
||||
// @ts-expect-error – storybook throws build error for (a: any, b: any)
|
||||
storySort: (a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }),
|
||||
},
|
||||
backgrounds: { disable: true },
|
||||
};
|
||||
|
||||
export const globalTypes = {
|
||||
theme: {
|
||||
name: 'Theme',
|
||||
description: 'Mantine color scheme',
|
||||
defaultValue: 'light',
|
||||
toolbar: {
|
||||
icon: 'mirror',
|
||||
items: [
|
||||
{ value: 'light', title: 'Light' },
|
||||
{ value: 'dark', title: 'Dark' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const decorators = [
|
||||
(renderStory: any, context: any) => {
|
||||
const scheme = (context.globals.theme || 'light') as 'light' | 'dark';
|
||||
return (
|
||||
<MantineProvider theme={theme} forceColorScheme={scheme}>
|
||||
<ColorSchemeScript />
|
||||
{renderStory()}
|
||||
</MantineProvider>
|
||||
);
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
.next
|
||||
out
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": ["stylelint-config-standard-scss"],
|
||||
"rules": {
|
||||
"custom-property-pattern": null,
|
||||
"selector-class-pattern": null,
|
||||
"scss/no-duplicate-mixins": null,
|
||||
"declaration-empty-line-before": null,
|
||||
"declaration-block-no-redundant-longhand-properties": null,
|
||||
"alpha-value-notation": null,
|
||||
"custom-property-empty-line-before": null,
|
||||
"property-no-vendor-prefix": null,
|
||||
"color-function-notation": null,
|
||||
"length-zero-no-unit": null,
|
||||
"selector-not-notation": null,
|
||||
"no-descending-specificity": null,
|
||||
"comment-empty-line-before": null,
|
||||
"scss/at-mixin-pattern": null,
|
||||
"scss/at-rule-no-unknown": null,
|
||||
"value-keyword-case": null,
|
||||
"media-feature-range-notation": null,
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoClasses": ["global"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+942
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||
@@ -0,0 +1,177 @@
|
||||
# Agent Instructions for Next.js + Mantine Project
|
||||
|
||||
This document provides context, rules, and workflows for AI agents operating in this codebase.
|
||||
|
||||
## 1. Project Overview & Commands
|
||||
|
||||
### Core Scripts
|
||||
|
||||
The project uses `npm` for dependency management and script execution.
|
||||
|
||||
- **Development Server:** `npm run dev`
|
||||
- Starts the Next.js development server on port 3000.
|
||||
- **Production Build:** `npm run build`
|
||||
- Creates an optimized production build.
|
||||
- **Start Production:** `npm run start`
|
||||
- Runs the built application in production mode.
|
||||
- **Type Check:** `npm run typecheck`
|
||||
- Runs TypeScript compiler (`tsc`) without emitting files to verify types.
|
||||
- **Storybook:** `npm run storybook`
|
||||
- Launches the Storybook UI environment for component development.
|
||||
|
||||
### Linting & Formatting
|
||||
|
||||
Always ensure code passes these checks before submitting changes.
|
||||
|
||||
- **Lint All:** `npm run lint` (Runs ESLint and Stylelint)
|
||||
- **ESLint:** `npm run eslint` (Checks JavaScript/TypeScript rules)
|
||||
- **Stylelint:** `npm run stylelint` (Checks CSS/SCSS modules)
|
||||
- **Prettier Check:** `npm run prettier:check` (Verifies formatting)
|
||||
- **Prettier Fix:** `npm run prettier:write` (Fixes formatting issues automatically)
|
||||
|
||||
### Testing
|
||||
|
||||
- **Run All Checks:** `npm test`
|
||||
- Comprehensive check: typegen, prettier, lint, typecheck, and unit tests.
|
||||
- **Run Unit Tests:** `npm run jest`
|
||||
- Runs the Jest test suite.
|
||||
- **Run Single Test File:** `npm run jest -- components/MyComponent/MyComponent.test.tsx`
|
||||
- **Critical:** Use this when working on a specific component to save time.
|
||||
- **Watch Mode:** `npm run jest:watch`
|
||||
|
||||
## 2. Architecture & File Structure
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```text
|
||||
.
|
||||
├── app/ # Next.js App Router pages and layouts
|
||||
├── components/ # Shared React components (Atomic design preferred)
|
||||
│ └── Feature/ # Feature-specific directory
|
||||
│ ├── Feature.tsx # Main component file
|
||||
│ ├── Feature.module.css # CSS Modules
|
||||
│ ├── Feature.story.tsx # Storybook file
|
||||
│ └── Feature.test.tsx # Jest test file
|
||||
├── public/ # Static assets (images, fonts, etc.)
|
||||
├── theme.ts # Mantine theme overrides and configuration
|
||||
└── ...config files
|
||||
```
|
||||
|
||||
### Framework Conventions
|
||||
|
||||
- **Next.js App Router:**
|
||||
- Use `page.tsx` for routes.
|
||||
- Use `layout.tsx` for wrapping pages.
|
||||
- default to **Server Components**.
|
||||
- Add `'use client';` at the very top of the file only when interactivity (hooks, event listeners) is required.
|
||||
- **Mantine UI:**
|
||||
- Use `@mantine/core` components for structure (`Stack`, `Group`, `Grid`) instead of raw `div`s with CSS flexbox.
|
||||
- Use `rem` functions for sizing to respect user settings.
|
||||
|
||||
## 3. Code Style & Standards
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **Strict Mode:** Enabled. No implicit `any`.
|
||||
- **Interfaces:** Prefer `interface` over `type` for object definitions.
|
||||
- **Props:** Define a specific interface for component props, exported if reusable.
|
||||
```typescript
|
||||
export interface MyComponentProps {
|
||||
title: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Components:** `PascalCase` (e.g., `UserProfile.tsx`).
|
||||
- **Functions/Hooks:** `camelCase` (e.g., `useAuth`, `handleSubmit`).
|
||||
- **CSS Modules:** `camelCase` for class names.
|
||||
```css
|
||||
/* styles.module.css */
|
||||
.container { ... } /* Good */
|
||||
.user-card { ... } /* Avoid kebab-case in modules if possible for dot notation access */
|
||||
```
|
||||
- **Tests:** `ComponentName.test.tsx`.
|
||||
|
||||
### Imports
|
||||
|
||||
- **Path Aliases:** Always use `@/` to refer to the project root.
|
||||
- `import { Button } from '@mantine/core';`
|
||||
- `import { MyComp } from '@/components/MyComp';`
|
||||
- **Sorting:** Imports are automatically sorted. Run `npm run prettier:write` if the linter complains.
|
||||
|
||||
## 4. Component Development Workflow
|
||||
|
||||
When creating or modifying a component (e.g., `UserProfile`), follow this checklist:
|
||||
|
||||
1. **Scaffold Files:**
|
||||
- `UserProfile.tsx`
|
||||
- `UserProfile.module.css`
|
||||
- `UserProfile.test.tsx`
|
||||
- `UserProfile.story.tsx`
|
||||
2. **Implementation:**
|
||||
- Define strict Props interface.
|
||||
- Use Mantine components for layout.
|
||||
- Use CSS Modules for custom styling not covered by Mantine props.
|
||||
3. **Theming:**
|
||||
- Use `theme` object from Mantine for colors/spacing.
|
||||
- Support light/dark mode using Mantine's mixins or standard CSS variables if needed.
|
||||
4. **Testing:**
|
||||
- Write a basic render test.
|
||||
- Test user interactions (clicks, inputs) using `@testing-library/user-event`.
|
||||
- Ensure accessibility (`aria-` attributes) if creating custom interactive elements.
|
||||
5. **Storybook:**
|
||||
- Create a basic story to visualize the component in isolation.
|
||||
|
||||
## 5. Testing & Verification
|
||||
|
||||
### Jest & React Testing Library
|
||||
|
||||
- **Queries:** Prioritize accessibility-based queries:
|
||||
1. `getByRole` (buttons, links, headings)
|
||||
2. `getByLabelText` (form inputs)
|
||||
3. `getByText` (non-interactive content)
|
||||
4. `getByTestId` (last resort)
|
||||
- **Mocking:**
|
||||
- Mock external modules utilizing `jest.mock`.
|
||||
- Use `jest.setup.cjs` for global mocks if needed (like `window.matchMedia`).
|
||||
|
||||
### Example Test Pattern
|
||||
|
||||
```tsx
|
||||
import { render, screen } from '@/test-utils'; // Use project test-utils if available
|
||||
import { MyComponent } from './MyComponent';
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<MyComponent title="Test" />);
|
||||
expect(screen.getByRole('heading', { name: /test/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 6. Error Handling & Best Practices
|
||||
|
||||
- **Async/Await:** Use `try/catch` blocks for API calls.
|
||||
- **Validation:** Use `zod` if installed for schema validation, otherwise use strict TypeScript checks.
|
||||
- **Accessibility:**
|
||||
- Ensure all `img` tags have `alt` text.
|
||||
- Ensure buttons have discernible text or `aria-label`.
|
||||
- Verify contrast ratios using the Storybook accessibility addon if available.
|
||||
- **Performance:**
|
||||
- Use `next/image` for images.
|
||||
- Avoid heavy computations in render cycles; use `useMemo` sparingly and only when proven necessary.
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
- **Style Issues:** If styles aren't applying, check if `postcss.config.cjs` is correctly processing the file and that the class is applied via `className={classes.myClass}`.
|
||||
- **Hydration Errors:** Ensure HTML structure is valid (no `div` inside `p`) and that the server/client output matches. Use `useEffect` for browser-only rendering if needed.
|
||||
- **Test Failures:** If `jest` fails on imports, check `jest.config.cjs` for `moduleNameMapper` settings matching `tsconfig.json` paths.
|
||||
|
||||
## 8. Documentation & External Resources
|
||||
|
||||
- **Mantine Documentation:**
|
||||
- **CRITICAL:** When implementing Mantine components or features, you MUST refer to the official AI-optimized documentation.
|
||||
- **URL:** [https://mantine.dev/llms.txt](https://mantine.dev/llms.txt)
|
||||
- Use the `WebFetch` tool to retrieve the latest patterns and examples from this URL if you are unsure about the implementation details or best practices for the current version.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Vitaly Rtischev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Mantine Next.js template
|
||||
|
||||
This is a template for [Next.js](https://nextjs.org/) app router + [Mantine](https://mantine.dev/).
|
||||
If you want to use pages router instead, see [next-pages-template](https://github.com/mantinedev/next-pages-template).
|
||||
|
||||
## Features
|
||||
|
||||
This template comes with the following features:
|
||||
|
||||
- [PostCSS](https://postcss.org/) with [mantine-postcss-preset](https://mantine.dev/styles/postcss-preset)
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Storybook](https://storybook.js.org/)
|
||||
- [Jest](https://jestjs.io/) setup with [React Testing Library](https://testing-library.com/docs/react-testing-library/intro)
|
||||
- ESLint setup with [eslint-config-mantine](https://github.com/mantinedev/eslint-config-mantine)
|
||||
|
||||
## npm scripts
|
||||
|
||||
### Build and dev scripts
|
||||
|
||||
- `dev` – start dev server
|
||||
- `build` – bundle application for production
|
||||
- `analyze` – analyzes application bundle with [@next/bundle-analyzer](https://www.npmjs.com/package/@next/bundle-analyzer)
|
||||
|
||||
### Testing scripts
|
||||
|
||||
- `typecheck` – checks TypeScript types
|
||||
- `lint` – runs ESLint
|
||||
- `prettier:check` – checks files with Prettier
|
||||
- `jest` – runs jest tests
|
||||
- `jest:watch` – starts jest watch
|
||||
- `test` – runs `jest`, `prettier:check`, `lint` and `typecheck` scripts
|
||||
|
||||
### Other scripts
|
||||
|
||||
- `storybook` – starts storybook dev server
|
||||
- `storybook:build` – build production storybook bundle to `storybook-static`
|
||||
- `prettier:write` – formats all files with Prettier
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { SignJWT } from 'jose';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || 'your-secret-key-at-least-32-chars-long'
|
||||
);
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { username, password } = await request.json();
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = await new SignJWT({ userId: user.id, username: user.username })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('30d')
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
const response = NextResponse.json({ message: 'Login successful' }, { status: 200 });
|
||||
|
||||
response.cookies.set('token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ message: 'Logged out successfully' }, { status: 200 });
|
||||
|
||||
response.cookies.set('token', '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 0, // Expire immediately
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { jwtVerify } from 'jose';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || 'your-secret-key-at-least-32-chars-long'
|
||||
);
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.cookies.get('token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ user: null }, { status: 200 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
return NextResponse.json({ user: payload }, { status: 200 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ user: null }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { username, password } = await request.json();
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json({ error: 'Username already exists' }, { status: 400 });
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
// Automatically login after register
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || 'your-secret-key-at-least-32-chars-long'
|
||||
);
|
||||
|
||||
// Import SignJWT dynamically to avoid top-level import if it causes issues, though it should be fine.
|
||||
// Better yet, let's keep it consistent with login.
|
||||
const { SignJWT } = await import('jose');
|
||||
|
||||
const token = await new SignJWT({ userId: user.id, username: user.username })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('30d')
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
const response = NextResponse.json(
|
||||
{ message: 'User created successfully', userId: user.id },
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
// Set cookie on response
|
||||
await response.cookies.set('token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
console.error('Registration error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || 'your-secret-key-at-least-32-chars-long'
|
||||
);
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.cookies.get('token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
const userId = payload.userId as string;
|
||||
|
||||
const chats = await prisma.chat.findMany({
|
||||
where: { userId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: {
|
||||
messages: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(chats, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Fetch chats error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const token = request.cookies.get('token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
const userId = payload.userId as string;
|
||||
const { messages, chatId } = await request.json();
|
||||
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return NextResponse.json({ error: 'Messages are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Determine chat ID or create new
|
||||
let currentChatId = chatId;
|
||||
|
||||
// Use the last message in the array, assuming it's the one to be saved
|
||||
const messageToSave = messages[messages.length - 1];
|
||||
|
||||
if (!currentChatId) {
|
||||
// Create new chat
|
||||
const firstMessageContent = messageToSave.content;
|
||||
const title =
|
||||
firstMessageContent.length > 30
|
||||
? `${firstMessageContent.substring(0, 30)}...`
|
||||
: firstMessageContent;
|
||||
|
||||
const newChat = await prisma.chat.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
},
|
||||
});
|
||||
currentChatId = newChat.id;
|
||||
}
|
||||
|
||||
const { content, role } = messageToSave;
|
||||
|
||||
if (!content || !role) {
|
||||
return NextResponse.json({ error: 'Invalid message format' }, { status: 400 });
|
||||
}
|
||||
|
||||
const message = await prisma.message.create({
|
||||
data: {
|
||||
content,
|
||||
role,
|
||||
chatId: currentChatId,
|
||||
},
|
||||
});
|
||||
|
||||
// Update chat updated_at
|
||||
await prisma.chat.update({
|
||||
where: { id: currentChatId },
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json({ message, chatId: currentChatId }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Save chat error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import '@mantine/core/styles.css';
|
||||
|
||||
import React from 'react';
|
||||
import { ColorSchemeScript, mantineHtmlProps } from '@mantine/core';
|
||||
import { DynamicThemeProvider } from '@/components/DynamicThemeProvider';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Mantine Next.js template',
|
||||
description: 'I am using Mantine with Next.js!',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: any }) {
|
||||
return (
|
||||
<html lang="en" {...mantineHtmlProps}>
|
||||
<head>
|
||||
<ColorSchemeScript />
|
||||
<link rel="shortcut icon" href="/favicon.svg" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<DynamicThemeProvider>{children}</DynamicThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ChatLayout from '@/components/Chat/ChatLayout';
|
||||
|
||||
export default function HomePage() {
|
||||
return <ChatLayout />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.chatBubble {
|
||||
max-width: 80%;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chatBubbleUser {
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.chatBubbleAssistant {
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { render, screen } from '@/test-utils';
|
||||
import ChatLayout from './ChatLayout';
|
||||
|
||||
describe('ChatLayout', () => {
|
||||
it('renders chat interface', () => {
|
||||
render(<ChatLayout />);
|
||||
expect(screen.getByText('AI Chat')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Type your message...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
IconLayoutSidebar,
|
||||
IconMessage,
|
||||
IconPlus,
|
||||
IconRobot,
|
||||
IconSend,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Avatar,
|
||||
Burger,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
rem,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useThemeContext } from '@/components/DynamicThemeProvider';
|
||||
import { SettingsModal } from '@/components/Settings/SettingsModal';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Chat {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
export default function ChatLayout() {
|
||||
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
|
||||
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
|
||||
const [settingsOpened, { open: openSettings, close: closeSettings }] = useDisclosure(false);
|
||||
const { primaryColor, setPrimaryColor } = useThemeContext();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
// State
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Hello! I am an AI assistant. How can I help you today?',
|
||||
},
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [isLoadingChats, setIsLoadingChats] = useState(false);
|
||||
|
||||
// Fetch chats on load
|
||||
useEffect(() => {
|
||||
fetchChats();
|
||||
}, [settingsOpened]); // Refresh when settings close (might have logged in/out)
|
||||
|
||||
const fetchChats = async () => {
|
||||
setIsLoadingChats(true);
|
||||
try {
|
||||
const res = await fetch('/api/chats');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setChats(data);
|
||||
} else {
|
||||
setChats([]);
|
||||
}
|
||||
} else {
|
||||
setChats([]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch chats', e);
|
||||
setChats([]);
|
||||
} finally {
|
||||
setIsLoadingChats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectChat = (chat: Chat) => {
|
||||
setActiveChatId(chat.id);
|
||||
if (chat.messages) {
|
||||
setMessages(chat.messages);
|
||||
} else {
|
||||
// In a real app we might fetch full messages here if not included in list
|
||||
setMessages([]);
|
||||
}
|
||||
if (mobileOpened) {
|
||||
toggleMobile();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewChat = () => {
|
||||
setActiveChatId(null);
|
||||
setMessages([
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: 'Hello! I am an AI assistant. How can I help you today?',
|
||||
},
|
||||
]);
|
||||
if (mobileOpened) {
|
||||
toggleMobile();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputValue.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: inputValue,
|
||||
};
|
||||
|
||||
// Optimistic update
|
||||
const newMessages = [...messages, userMessage];
|
||||
setMessages(newMessages);
|
||||
setInputValue('');
|
||||
|
||||
try {
|
||||
// Save to backend
|
||||
const res = await fetch('/api/chats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: [userMessage],
|
||||
chatId: activeChatId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.chatId && data.chatId !== activeChatId) {
|
||||
setActiveChatId(data.chatId);
|
||||
fetchChats(); // Refresh list to show new chat
|
||||
}
|
||||
|
||||
// Simulate AI response
|
||||
setTimeout(async () => {
|
||||
const responseMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content:
|
||||
'I am a simulated AI response. I do not have a backend yet. I just repeat that I am simulated.',
|
||||
};
|
||||
|
||||
const updatedMessages = [...newMessages, responseMessage];
|
||||
setMessages(updatedMessages);
|
||||
|
||||
// Save AI response to backend
|
||||
try {
|
||||
await fetch('/api/chats', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: [responseMessage],
|
||||
chatId: data.chatId || activeChatId,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to save message', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppShell
|
||||
header={{ height: 60 }}
|
||||
navbar={{
|
||||
width: 300,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Group>
|
||||
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
||||
<Tooltip label="Toggle Sidebar">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={toggleDesktop} visibleFrom="sm">
|
||||
<IconLayoutSidebar size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<IconRobot size={28} stroke={1.5} color={theme.colors[primaryColor][6]} />
|
||||
<Title order={3}>AI Chat</Title>
|
||||
</Group>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={openSettings}>
|
||||
<IconSettings size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar
|
||||
p="md"
|
||||
style={{ borderRight: '1px solid var(--mantine-color-default-border)' }}
|
||||
>
|
||||
<Stack gap="sm" h="100%">
|
||||
<Group justify="space-between">
|
||||
<Title order={5} c="dimmed">
|
||||
History
|
||||
</Title>
|
||||
<Tooltip label="New Chat">
|
||||
<ActionIcon variant="light" color={primaryColor} onClick={handleNewChat}>
|
||||
<IconPlus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<ScrollArea style={{ flex: 1, margin: '0 -10px' }} p="xs">
|
||||
<Stack gap="xs">
|
||||
{chats.length > 0 ? (
|
||||
chats.map((chat) => (
|
||||
<UnstyledButton
|
||||
key={chat.id}
|
||||
onClick={() => handleSelectChat(chat)}
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
backgroundColor:
|
||||
activeChatId === chat.id
|
||||
? 'var(--mantine-color-default-hover)'
|
||||
: 'transparent',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Group wrap="nowrap">
|
||||
<IconMessage size={18} color="gray" style={{ minWidth: 18 }} />
|
||||
<Text size="sm" truncate>
|
||||
{chat.title}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" mt="xl">
|
||||
{isLoadingChats ? 'Loading...' : 'No saved chats'}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Container
|
||||
size="lg"
|
||||
h="calc(100vh - 100px)"
|
||||
style={{ display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<ScrollArea flex={1} mb="md" type="auto" offsetScrollbars>
|
||||
<Stack gap="xl" px="md" py="lg">
|
||||
{messages.map((message) => (
|
||||
<Group
|
||||
key={message.id}
|
||||
justify={message.role === 'user' ? 'flex-end' : 'flex-start'}
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
>
|
||||
{message.role === 'assistant' && (
|
||||
<Avatar radius="xl" color={primaryColor} variant="light">
|
||||
<IconRobot size={20} />
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
bg={
|
||||
message.role === 'user'
|
||||
? 'var(--mantine-color-default-hover)'
|
||||
: 'transparent'
|
||||
}
|
||||
style={{
|
||||
maxWidth: '80%',
|
||||
borderTopLeftRadius: message.role === 'assistant' ? 0 : undefined,
|
||||
borderTopRightRadius: message.role === 'user' ? 0 : undefined,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" style={{ lineHeight: 1.6 }}>
|
||||
{message.content}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
{message.role === 'user' && (
|
||||
<Avatar radius="xl" color="gray" variant="light">
|
||||
<IconUser size={20} />
|
||||
</Avatar>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
p="xs"
|
||||
radius="xl"
|
||||
shadow="sm"
|
||||
style={{
|
||||
transition: 'border-color 0.2s ease',
|
||||
borderColor: isInputFocused ? theme.colors[primaryColor][6] : undefined,
|
||||
}}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<TextInput
|
||||
variant="unstyled"
|
||||
placeholder="Type your message..."
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
style={{ flex: 1, paddingLeft: rem(10) }}
|
||||
size="md"
|
||||
/>
|
||||
<ActionIcon
|
||||
onClick={handleSendMessage}
|
||||
variant="filled"
|
||||
color={primaryColor}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
disabled={!inputValue.trim()}
|
||||
>
|
||||
<IconSend size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Container>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
<SettingsModal
|
||||
opened={settingsOpened}
|
||||
close={closeSettings}
|
||||
primaryColor={primaryColor}
|
||||
setPrimaryColor={setPrimaryColor}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import { createTheme, MantineProvider } from '@mantine/core';
|
||||
|
||||
interface ThemeContextType {
|
||||
primaryColor: string;
|
||||
setPrimaryColor: (color: string) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
primaryColor: 'blue',
|
||||
setPrimaryColor: () => {},
|
||||
});
|
||||
|
||||
export const useThemeContext = () => useContext(ThemeContext);
|
||||
|
||||
export function DynamicThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [primaryColor, setPrimaryColor] = useState('blue');
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor,
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ primaryColor, setPrimaryColor }}>
|
||||
<MantineProvider theme={theme} defaultColorScheme="auto">
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconAlertCircle, IconPalette, IconUser, IconX } from '@tabler/icons-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Button,
|
||||
ColorSwatch,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
NavLink,
|
||||
PasswordInput,
|
||||
rem,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface SettingsModalProps {
|
||||
opened: boolean;
|
||||
close: () => void;
|
||||
primaryColor: string;
|
||||
setPrimaryColor: (color: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
opened,
|
||||
close,
|
||||
primaryColor,
|
||||
setPrimaryColor,
|
||||
}: SettingsModalProps) {
|
||||
const theme = useMantineTheme();
|
||||
const [activeTab, setActiveTab] = useState<'appearance' | 'account'>('appearance');
|
||||
|
||||
// Account State
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoginMode, setIsLoginMode] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Check login status on mount
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
fetchUser();
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me');
|
||||
const data = await res.json();
|
||||
if (data.user) {
|
||||
setUser(data.user);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuth = async () => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
const endpoint = isLoginMode ? '/api/auth/login' : '/api/auth/register';
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Something went wrong');
|
||||
}
|
||||
|
||||
// Refresh user state
|
||||
await fetchUser();
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const colors = Object.keys(theme.colors).filter(
|
||||
(color) => color !== 'dark' && color !== 'gray' && color !== 'white' && color !== 'black'
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
padding={0}
|
||||
radius="xl"
|
||||
>
|
||||
<Group align="stretch" gap={0} style={{ minHeight: 400, overflow: 'hidden' }}>
|
||||
{/* Left Sidebar */}
|
||||
<Stack
|
||||
gap="xs"
|
||||
w={220}
|
||||
p="sm"
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{
|
||||
borderRight: '1px solid var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
<NavLink
|
||||
active={activeTab === 'appearance'}
|
||||
label="Appearance"
|
||||
leftSection={<IconPalette size={18} stroke={1.5} />}
|
||||
variant="light"
|
||||
color={primaryColor}
|
||||
onClick={() => setActiveTab('appearance')}
|
||||
style={{ borderRadius: 'var(--mantine-radius-lg)' }}
|
||||
/>
|
||||
<NavLink
|
||||
active={activeTab === 'account'}
|
||||
label="Account"
|
||||
leftSection={<IconUser size={18} stroke={1.5} />}
|
||||
variant="light"
|
||||
color={primaryColor}
|
||||
onClick={() => setActiveTab('account')}
|
||||
style={{ borderRadius: 'var(--mantine-radius-lg)' }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Right Content */}
|
||||
<Stack p="xl" style={{ flex: 1, position: 'relative' }}>
|
||||
<ActionIcon
|
||||
onClick={close}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
style={{ position: 'absolute', top: rem(15), right: rem(15), zIndex: 1 }}
|
||||
>
|
||||
<IconX size={20} />
|
||||
</ActionIcon>
|
||||
|
||||
{activeTab === 'appearance' && (
|
||||
<>
|
||||
<Title order={4}>Appearance</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Customize the look and feel of the application.
|
||||
</Text>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Accent Color
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{colors.map((color) => (
|
||||
<ColorSwatch
|
||||
key={color}
|
||||
component="button"
|
||||
color={theme.colors[color][6]}
|
||||
onClick={() => setPrimaryColor(color)}
|
||||
style={{ color: '#fff', cursor: 'pointer' }}
|
||||
withShadow
|
||||
>
|
||||
{primaryColor === color && <IconPalette size={12} />}
|
||||
</ColorSwatch>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'account' && (
|
||||
<>
|
||||
<Title order={4}>Account</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Manage your account and chat history.
|
||||
</Text>
|
||||
<Divider my="sm" />
|
||||
|
||||
{user ? (
|
||||
<Stack>
|
||||
<Text>
|
||||
Logged in as <b>{user.username}</b>
|
||||
</Text>
|
||||
<Button color="red" variant="light" onClick={handleLogout}>
|
||||
Log out
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack>
|
||||
{error && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Enter username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Group justify="space-between" mt="md">
|
||||
<Text
|
||||
size="xs"
|
||||
style={{ cursor: 'pointer' }}
|
||||
c="blue"
|
||||
onClick={() => setIsLoginMode(!isLoginMode)}
|
||||
>
|
||||
{isLoginMode ? 'Need an account? Register' : 'Have an account? Login'}
|
||||
</Text>
|
||||
<Button onClick={handleAuth} loading={loading} color={primaryColor}>
|
||||
{isLoginMode ? 'Login' : 'Register'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import mantine from 'eslint-config-mantine';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
// @ts-check
|
||||
export default defineConfig(
|
||||
tseslint.configs.recommended,
|
||||
...mantine,
|
||||
{ ignores: ['**/*.{mjs,cjs,js,d.ts,d.mts}', '.next'] },
|
||||
{
|
||||
files: ['**/*.story.tsx'],
|
||||
rules: { 'no-console': 'off' },
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: process.cwd(),
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
const nextJest = require('next/jest');
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
dir: './',
|
||||
});
|
||||
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
|
||||
moduleNameMapper: {
|
||||
'^@/components/(.*)$': '<rootDir>/components/$1',
|
||||
'^@/pages/(.*)$': '<rootDir>/pages/$1',
|
||||
},
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
};
|
||||
|
||||
module.exports = createJestConfig(customJestConfig);
|
||||
@@ -0,0 +1,27 @@
|
||||
require('@testing-library/jest-dom');
|
||||
|
||||
const { getComputedStyle } = window;
|
||||
window.getComputedStyle = (elt) => getComputedStyle(elt);
|
||||
window.HTMLElement.prototype.scrollIntoView = () => {};
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ||
|
||||
new PrismaClient({
|
||||
log: ['query'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import bundleAnalyzer from '@next/bundle-analyzer';
|
||||
|
||||
const withBundleAnalyzer = bundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
});
|
||||
|
||||
export default withBundleAnalyzer({
|
||||
reactStrictMode: false,
|
||||
experimental: {
|
||||
optimizePackageImports: ['@mantine/core', '@mantine/hooks'],
|
||||
},
|
||||
});
|
||||
Generated
+18636
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "mantine-next-template",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"analyze": "ANALYZE=true next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "npm run eslint && npm run stylelint",
|
||||
"eslint": "eslint .",
|
||||
"stylelint": "stylelint '**/*.css' --cache",
|
||||
"jest": "jest",
|
||||
"jest:watch": "jest --watch",
|
||||
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"test": "npx next typegen && npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^8.3.12",
|
||||
"@mantine/hooks": "^8.3.12",
|
||||
"@next/bundle-analyzer": "^16.0.0",
|
||||
"@prisma/client": "^5.10.2",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"jose": "^6.1.3",
|
||||
"next": "16.1.1",
|
||||
"prisma": "^5.10.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.4",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@storybook/addon-themes": "^10.0.0",
|
||||
"@storybook/nextjs": "^10.0.0",
|
||||
"@storybook/react": "^10.0.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/eslint-plugin-jsx-a11y": "^6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react": "19.2.8",
|
||||
"babel-loader": "^10.0.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-config-mantine": "^4.0.3",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-mantine": "1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"storybook": "^10.0.0",
|
||||
"stylelint": "^16.25.0",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"ts-jest": "^29.4.4",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "^8.46.0"
|
||||
},
|
||||
"packageManager": "yarn@4.12.0"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import 'dotenv/config';
|
||||
|
||||
import { defineConfig } from 'prisma/config';
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
datasource: {
|
||||
url: 'file:./dev.db',
|
||||
},
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,29 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"username" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Chat" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "Chat_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Message" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"role" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"chatId" TEXT NOT NULL,
|
||||
CONSTRAINT "Message_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
@@ -0,0 +1,37 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
password String
|
||||
chats Chat[]
|
||||
}
|
||||
|
||||
model Chat {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
title String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
messages Message[]
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
role String // 'user' or 'assistant'
|
||||
content String
|
||||
chatId String
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g fill="none" fill-rule="evenodd"><rect width="500" height="500" fill="#339AF0" rx="250"/><g fill="#FFF"><path fill-rule="nonzero" d="M202.055 135.706c-6.26 8.373-4.494 20.208 3.944 26.42 29.122 21.45 45.824 54.253 45.824 90.005 0 35.752-16.702 68.559-45.824 90.005-8.436 6.215-10.206 18.043-3.944 26.42 6.26 8.378 18.173 10.13 26.611 3.916a153.835 153.835 0 0024.509-22.54h53.93c10.506 0 19.023-8.455 19.023-18.885 0-10.43-8.517-18.886-19.023-18.886h-29.79c8.196-18.594 12.553-38.923 12.553-60.03s-4.357-41.436-12.552-60.03h29.79c10.505 0 19.022-8.455 19.022-18.885 0-10.43-8.517-18.886-19.023-18.886h-53.93a153.835 153.835 0 00-24.509-22.54c-8.438-6.215-20.351-4.46-26.61 3.916z"/><path d="M171.992 246.492c0-15.572 12.624-28.195 28.196-28.195 15.572 0 28.195 12.623 28.195 28.195 0 15.572-12.623 28.196-28.195 28.196-15.572 0-28.196-12.624-28.196-28.196z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 937 B |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"schedule": ["before 5am on sunday"],
|
||||
"groupName": "all dependencies",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["*"],
|
||||
"groupName": "all dependencies"
|
||||
}
|
||||
],
|
||||
"prHourlyLimit": 0,
|
||||
"prConcurrentLimit": 0
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const users = await prisma.user.findMany();
|
||||
console.log('Users:', users);
|
||||
} catch (e) {
|
||||
console.error('Error:', e);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,5 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
export * from '@testing-library/react';
|
||||
export { render } from './render';
|
||||
export { userEvent };
|
||||
@@ -0,0 +1,13 @@
|
||||
import { render as testingLibraryRender } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { theme } from '../theme';
|
||||
|
||||
export function render(ui: React.ReactNode) {
|
||||
return testingLibraryRender(<>{ui}</>, {
|
||||
wrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider theme={theme} env="test">
|
||||
{children}
|
||||
</MantineProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { createTheme } from '@mantine/core';
|
||||
|
||||
export const theme = createTheme({
|
||||
/* Put your mantine theme override here */
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["node", "jest", "@testing-library/jest-dom"],
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".storybook/main.ts",
|
||||
".storybook/preview.tsx",
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user