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