This commit is contained in:
Flatlogic Bot 2026-05-14 15:36:14 +00:00
parent 9eaef58312
commit ccb52987a0
18 changed files with 3792 additions and 985 deletions

View File

@ -57,4 +57,28 @@ router.post(
}),
);
router.post(
'/conversations/:id/messages/:messageId/retry',
wrapAsync(async (req, res) => {
const payload = await WorkspaceService.retryMessage(
req.params.id,
req.params.messageId,
req.currentUser,
);
res.status(200).send(payload);
}),
);
router.post(
'/conversations/:id/messages/:messageId/regenerate',
wrapAsync(async (req, res) => {
const payload = await WorkspaceService.regenerateMessage(
req.params.id,
req.params.messageId,
req.currentUser,
);
res.status(200).send(payload);
}),
);
module.exports = router;

View File

@ -1,4 +1,5 @@
const db = require('../db/models');
const { LocalAIApi } = require('../ai/LocalAIApi');
const ValidationError = require('./notifications/errors/validation');
const { Op } = db.Sequelize;
@ -6,6 +7,7 @@ const { Op } = db.Sequelize;
const DEFAULT_CONVERSATION_TITLE = 'New conversation';
const MAX_MESSAGE_LENGTH = 8000;
const MAX_TITLE_LENGTH = 120;
const MAX_CONTEXT_MESSAGES = 12;
function normalizeText(value) {
if (typeof value !== 'string') {
@ -62,103 +64,329 @@ function estimateTokens(content) {
return Math.max(1, Math.ceil(normalized.length / 4));
}
function buildDraftParagraph(message, agentName) {
const excerpt = cleanMarkdownPreview(message).slice(0, 220) || 'your request';
function buildAssistantFailureMessage(errorMessage) {
const normalized = cleanMarkdownPreview(errorMessage).slice(0, 400) || 'Unknown AI error.';
return `I captured the main goal from **${excerpt}** and prepared a structured draft in the **${agentName}** style. This workspace is ready for a real model later, so the current reply is a product-shell response that keeps the chat flow working end to end.`;
}
function buildCodeExample() {
return [
'```ts',
'type Result = {',
' summary: string;',
' nextStep: string;',
'};',
'### Response failed',
'',
'export function respond(prompt: string): Result {',
' return {',
" summary: `Working on: ${prompt.trim()}`,",
" nextStep: 'Refine the prompt or ask for implementation details.',",
' };',
'}',
'The workspace could not generate a reply from the configured AI provider.',
'',
'```text',
normalized,
'```',
'',
'Retry the message or switch to another agent and try again.',
].join('\n');
}
function buildAssistantReply({ message, agent }) {
const agentName = agent?.name || 'Assistant';
const normalized = normalizeText(message);
const lowerCase = normalized.toLowerCase();
const asksForCode = /(code|debug|bug|typescript|javascript|python|sql|query|api|component|react|next|function)/i.test(
lowerCase,
);
const asksForWriting = /(write|draft|copy|email|summary|summarize|post|documentation|spec)/i.test(
lowerCase,
);
function buildAiInput(agent, historyMessages) {
const input = [];
const systemPrompt = normalizeText(agent?.system_prompt);
const agentDescription = normalizeText(agent?.description);
const intro = `### ${agentName}`;
const context = buildDraftParagraph(normalized, agentName);
const echoedPrompt = `> ${cleanMarkdownPreview(normalized).slice(0, 240)}${
cleanMarkdownPreview(normalized).length > 240 ? '…' : ''
}`;
if (asksForCode) {
return [
intro,
'',
context,
'',
echoedPrompt,
'',
'#### Suggested path',
'- Start with the smallest working example.',
'- Validate inputs and define the expected output shape.',
'- Add edge-case handling after the first happy-path pass.',
'',
'#### Starter example',
buildCodeExample(),
'',
'If you want, ask me for the production version next and I can continue from this structure.',
].join('\n');
if (systemPrompt) {
input.push({
role: 'system',
content: systemPrompt,
});
} else if (agentDescription) {
input.push({
role: 'system',
content: `You are ${agent?.name || 'an AI assistant'}. ${agentDescription}`,
});
}
if (asksForWriting) {
return [
intro,
'',
context,
'',
echoedPrompt,
'',
'#### First draft',
'- Open with the outcome you want the reader to understand.',
'- Keep the tone concise, direct, and useful.',
'- End with one clear next action or decision.',
'',
'#### Polished version',
'Here is a strong starting point you can refine in the next turn:',
'',
`**Goal:** move the work forward without losing context.`,
'',
'I can now turn this into a finished draft, a shorter version, or a more opinionated rewrite.',
].join('\n');
for (const message of historyMessages) {
if (!['user', 'assistant', 'system'].includes(message.role)) {
continue;
}
const content = normalizeText(message.content_markdown || message.content);
if (!content) {
continue;
}
input.push({
role: message.role,
content,
});
}
return [
intro,
'',
context,
'',
echoedPrompt,
'',
'#### Working response',
'- I understood the request and attached it to this conversation.',
'- The conversation history is now saved in your workspace.',
'- You can rename, archive, reopen, or delete this chat at any time.',
'',
'Tell me whether you want a **plan**, a **draft answer**, or a **code solution**, and I will continue in that format.',
].join('\n');
return input;
}
async function requestAssistantReply(conversationId, assistantMessageId, agent) {
const historyMessages = await db.messages.findAll({
where: {
conversationId,
id: {
[Op.ne]: assistantMessageId,
},
delivery_status: {
[Op.ne]: 'failed',
},
role: {
[Op.in]: ['user', 'assistant', 'system'],
},
},
order: [['sequence', 'DESC']],
limit: MAX_CONTEXT_MESSAGES,
});
const orderedMessages = [...historyMessages].reverse();
const input = buildAiInput(agent, orderedMessages);
if (input.length === 0) {
throw new Error('AI input could not be built from the conversation history.');
}
const payload = {
input,
};
if (agent?.model) {
payload.model = agent.model;
}
if (agent?.temperature !== undefined && agent?.temperature !== null && agent.temperature !== '') {
payload.temperature = Number(agent.temperature);
}
if (agent?.max_output_tokens) {
payload.max_output_tokens = Number(agent.max_output_tokens);
}
const response = await LocalAIApi.createResponse(payload, {
poll_interval: 5,
poll_timeout: 300,
});
if (!response.success) {
throw new Error(response.error || response.message || 'AI proxy request failed.');
}
const text = normalizeText(LocalAIApi.extractText(response));
if (!text) {
throw new Error('AI response was empty.');
}
return {
content: text,
model: payload.model || 'default-ai-model',
};
}
async function findConversationMessage(conversationId, messageId, transaction) {
return db.messages.findOne({
where: {
id: messageId,
conversationId,
},
transaction,
});
}
async function findLatestUserMessageBeforeSequence(conversationId, sequence, transaction) {
return db.messages.findOne({
where: {
conversationId,
role: 'user',
sequence: {
[Op.lt]: sequence,
},
},
order: [['sequence', 'DESC']],
transaction,
});
}
async function resolveConversationAgent(conversation, currentUser, transaction) {
let agent = conversation.agent;
if (agent) {
return agent;
}
agent = await findDefaultAgent(transaction);
if (!agent) {
return null;
}
await conversation.update(
{
agentId: agent.id,
updatedById: currentUser.id,
},
{ transaction },
);
return agent;
}
async function finalizeAssistantResponse({
assistantMessageId,
conversationId,
currentUser,
sourceContent,
agentId,
agentModel,
titleSeed,
metadataAction,
}) {
let aiError = null;
try {
const agent =
agentId
? await db.agents.findOne({
where: {
id: agentId,
},
})
: null;
const aiResult = await requestAssistantReply(conversationId, assistantMessageId, agent);
const completeTransaction = await db.sequelize.transaction();
try {
const completedAt = new Date();
const assistantMessage = await db.messages.findOne({
where: {
id: assistantMessageId,
},
transaction: completeTransaction,
});
if (!assistantMessage) {
throw new Error('Assistant message placeholder was not found.');
}
await assistantMessage.update(
{
content: aiResult.content,
content_markdown: aiResult.content,
delivery_status: 'completed',
completed_at: completedAt,
updatedById: currentUser.id,
},
{ transaction: completeTransaction },
);
const conversation = await db.conversations.findOne({
where: {
id: conversationId,
},
transaction: completeTransaction,
});
if (!conversation) {
throw new Error('Conversation was not found while completing the assistant response.');
}
const conversationUpdate = {
summary: buildSummary(aiResult.content),
last_message_at: completedAt,
updatedById: currentUser.id,
};
if (!conversation.title || conversation.title === DEFAULT_CONVERSATION_TITLE) {
conversationUpdate.title = buildConversationTitle(titleSeed);
}
await conversation.update(conversationUpdate, { transaction: completeTransaction });
const inputTokens = estimateTokens(sourceContent);
const outputTokens = estimateTokens(aiResult.content);
await createUsageEvent(
{
event_type: 'message_generated',
occurred_at: completedAt,
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
cost_usd: 0,
provider: 'local-ai',
model: aiResult.model || agentModel,
metadata_json: JSON.stringify({
role: 'assistant',
action: metadataAction,
}),
conversationId,
messageId: assistantMessageId,
agentId,
},
completeTransaction,
currentUser,
);
await completeTransaction.commit();
} catch (error) {
await completeTransaction.rollback();
throw error;
}
} catch (error) {
aiError = error.message || String(error);
const failTransaction = await db.sequelize.transaction();
try {
const failedAt = new Date();
const assistantMessage = await db.messages.findOne({
where: {
id: assistantMessageId,
},
transaction: failTransaction,
});
if (!assistantMessage) {
throw new Error('Assistant message placeholder was not found while handling the AI failure.');
}
const failureMessage = buildAssistantFailureMessage(aiError);
await assistantMessage.update(
{
content: failureMessage,
content_markdown: failureMessage,
delivery_status: 'failed',
completed_at: failedAt,
updatedById: currentUser.id,
},
{ transaction: failTransaction },
);
const conversation = await db.conversations.findOne({
where: {
id: conversationId,
},
transaction: failTransaction,
});
if (!conversation) {
throw new Error('Conversation was not found while marking the AI failure.');
}
const conversationUpdate = {
last_message_at: failedAt,
updatedById: currentUser.id,
};
if (!conversation.title || conversation.title === DEFAULT_CONVERSATION_TITLE) {
conversationUpdate.title = buildConversationTitle(titleSeed);
}
await conversation.update(conversationUpdate, { transaction: failTransaction });
await failTransaction.commit();
} catch (failureHandlingError) {
await failTransaction.rollback();
throw failureHandlingError;
}
}
return aiError;
}
function serializeAgent(agent) {
@ -556,20 +784,25 @@ module.exports = class WorkspaceService {
}
static async sendMessage(id, data, currentUser) {
const transaction = await db.sequelize.transaction();
const content = normalizeText(data?.content);
if (!content) {
throw new ValidationError();
}
if (content.length > MAX_MESSAGE_LENGTH) {
throw new ValidationError();
}
const createTransaction = await db.sequelize.transaction();
let conversationId = null;
let assistantMessageId = null;
let userMessageId = null;
let agentId = null;
let agentModel = null;
try {
const content = normalizeText(data?.content);
if (!content) {
throw new ValidationError();
}
if (content.length > MAX_MESSAGE_LENGTH) {
throw new ValidationError();
}
const conversation = await findOwnedConversation(id, currentUser, transaction);
const conversation = await findOwnedConversation(id, currentUser, createTransaction);
let agent = conversation.agent;
if (data?.agentId) {
@ -578,7 +811,7 @@ module.exports = class WorkspaceService {
id: data.agentId,
is_active: true,
},
transaction,
transaction: createTransaction,
});
if (!agent) {
@ -590,35 +823,21 @@ module.exports = class WorkspaceService {
agentId: agent.id,
updatedById: currentUser.id,
},
{ transaction },
{ transaction: createTransaction },
);
}
if (!agent) {
agent = await findDefaultAgent(transaction);
if (agent) {
await conversation.update(
{
agentId: agent.id,
updatedById: currentUser.id,
},
{ transaction },
);
}
agent = await resolveConversationAgent(conversation, currentUser, createTransaction);
}
const messageCount = await db.messages.count({
where: {
conversationId: conversation.id,
},
transaction,
transaction: createTransaction,
});
const sentAt = new Date();
const assistantReply = buildAssistantReply({
message: content,
agent,
});
const userMessage = await db.messages.create(
{
@ -634,44 +853,36 @@ module.exports = class WorkspaceService {
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
{ transaction: createTransaction },
);
const assistantMessage = await db.messages.create(
{
role: 'assistant',
content: assistantReply,
content_markdown: assistantReply,
delivery_status: 'completed',
content: '',
content_markdown: '',
delivery_status: 'streaming',
sent_at: sentAt,
completed_at: new Date(sentAt.getTime() + 300),
completed_at: null,
sequence: messageCount + 2,
conversationId: conversation.id,
author_userId: currentUser.id,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
{ transaction: createTransaction },
);
const nextTitle =
conversation.title && conversation.title !== DEFAULT_CONVERSATION_TITLE
? conversation.title
: buildConversationTitle(content);
await conversation.update(
{
title: nextTitle,
status: 'active',
summary: buildSummary(assistantReply),
last_message_at: sentAt,
updatedById: currentUser.id,
},
{ transaction },
{ transaction: createTransaction },
);
const inputTokens = estimateTokens(content);
const outputTokens = estimateTokens(assistantReply);
await createUsageEvent(
{
@ -681,8 +892,8 @@ module.exports = class WorkspaceService {
output_tokens: 0,
total_tokens: inputTokens,
cost_usd: 0,
provider: 'workspace-shell',
model: agent?.model || 'shell-draft',
provider: 'local-ai',
model: agent?.model || 'default-ai-model',
metadata_json: JSON.stringify({
role: 'user',
}),
@ -690,36 +901,236 @@ module.exports = class WorkspaceService {
messageId: userMessage.id,
agentId: agent?.id || null,
},
transaction,
createTransaction,
currentUser,
);
await createUsageEvent(
{
event_type: 'message_generated',
occurred_at: new Date(sentAt.getTime() + 300),
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
cost_usd: 0,
provider: 'workspace-shell',
model: agent?.model || 'shell-draft',
metadata_json: JSON.stringify({
role: 'assistant',
}),
await createTransaction.commit();
conversationId = conversation.id;
assistantMessageId = assistantMessage.id;
userMessageId = userMessage.id;
agentId = agent?.id || null;
agentModel = agent?.model || 'default-ai-model';
} catch (error) {
await createTransaction.rollback();
throw error;
}
const aiError = await finalizeAssistantResponse({
assistantMessageId,
conversationId,
currentUser,
sourceContent: content,
agentId,
agentModel,
titleSeed: content,
metadataAction: 'initial',
});
const payload = await this.getConversation(conversationId, currentUser);
if (aiError) {
return {
...payload,
ai_error: aiError,
};
}
return payload;
}
static async retryMessage(id, messageId, currentUser) {
const transaction = await db.sequelize.transaction();
let conversationId = null;
let assistantMessageId = null;
let sourceContent = '';
let agentId = null;
let agentModel = null;
try {
const conversation = await findOwnedConversation(id, currentUser, transaction);
const assistantMessage = await findConversationMessage(conversation.id, messageId, transaction);
if (!assistantMessage || assistantMessage.role !== 'assistant' || assistantMessage.delivery_status !== 'failed') {
throw new ValidationError();
}
const newerUserMessageCount = await db.messages.count({
where: {
conversationId: conversation.id,
messageId: assistantMessage.id,
agentId: agent?.id || null,
role: 'user',
sequence: {
[Op.gt]: assistantMessage.sequence,
},
},
transaction,
currentUser,
});
if (newerUserMessageCount > 0) {
throw new ValidationError();
}
const sourceMessage = await findLatestUserMessageBeforeSequence(
conversation.id,
assistantMessage.sequence,
transaction,
);
if (!sourceMessage) {
throw new ValidationError();
}
const agent = await resolveConversationAgent(conversation, currentUser, transaction);
await assistantMessage.update(
{
content: '',
content_markdown: '',
delivery_status: 'streaming',
completed_at: null,
updatedById: currentUser.id,
},
{ transaction },
);
await conversation.update(
{
status: 'active',
updatedById: currentUser.id,
},
{ transaction },
);
await transaction.commit();
return this.getConversation(conversation.id, currentUser);
conversationId = conversation.id;
assistantMessageId = assistantMessage.id;
sourceContent = sourceMessage.content_markdown || sourceMessage.content || '';
agentId = agent?.id || null;
agentModel = agent?.model || 'default-ai-model';
} catch (error) {
await transaction.rollback();
throw error;
}
const aiError = await finalizeAssistantResponse({
assistantMessageId,
conversationId,
currentUser,
sourceContent,
agentId,
agentModel,
titleSeed: sourceContent,
metadataAction: 'retry',
});
const payload = await this.getConversation(conversationId, currentUser);
if (aiError) {
return {
...payload,
ai_error: aiError,
};
}
return payload;
}
static async regenerateMessage(id, messageId, currentUser) {
const transaction = await db.sequelize.transaction();
let conversationId = null;
let assistantMessageId = null;
let sourceContent = '';
let agentId = null;
let agentModel = null;
try {
const conversation = await findOwnedConversation(id, currentUser, transaction);
const assistantMessage = await findConversationMessage(conversation.id, messageId, transaction);
if (!assistantMessage || assistantMessage.role !== 'assistant') {
throw new ValidationError();
}
const newerUserMessageCount = await db.messages.count({
where: {
conversationId: conversation.id,
role: 'user',
sequence: {
[Op.gt]: assistantMessage.sequence,
},
},
transaction,
});
if (newerUserMessageCount > 0) {
throw new ValidationError();
}
const sourceMessage = await findLatestUserMessageBeforeSequence(
conversation.id,
assistantMessage.sequence,
transaction,
);
if (!sourceMessage) {
throw new ValidationError();
}
const agent = await resolveConversationAgent(conversation, currentUser, transaction);
await assistantMessage.update(
{
content: '',
content_markdown: '',
delivery_status: 'streaming',
completed_at: null,
updatedById: currentUser.id,
},
{ transaction },
);
await conversation.update(
{
status: 'active',
updatedById: currentUser.id,
},
{ transaction },
);
await transaction.commit();
conversationId = conversation.id;
assistantMessageId = assistantMessage.id;
sourceContent = sourceMessage.content_markdown || sourceMessage.content || '';
agentId = agent?.id || null;
agentModel = agent?.model || 'default-ai-model';
} catch (error) {
await transaction.rollback();
throw error;
}
const aiError = await finalizeAssistantResponse({
assistantMessageId,
conversationId,
currentUser,
sourceContent,
agentId,
agentModel,
titleSeed: sourceContent,
metadataAction: 'regenerate',
});
const payload = await this.getConversation(conversationId, currentUser);
if (aiError) {
return {
...payload,
ai_error: aiError,
};
}
return payload;
}
};

View File

@ -0,0 +1,147 @@
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
import menuNavBar from '../menuNavBar'
import BaseIcon from '../components/BaseIcon'
import NavBar from '../components/NavBar'
import NavBarItemPlain from '../components/NavBarItemPlain'
import AsideMenu from '../components/AsideMenu'
import FooterBar from '../components/FooterBar'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Search from '../components/Search';
import { useRouter } from 'next/router'
import {findMe, logoutUser} from "../stores/authSlice";
import Link from 'next/link';
import {hasPermission} from "../helpers/userPermissions";
type Props = {
children: ReactNode
permission?: string
}
export default function LayoutAuthenticated({
children,
permission
}: Props) {
const dispatch = useAppDispatch()
const router = useRouter()
const { token, currentUser } = useAppSelector((state) => state.auth)
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const isWorkspaceRoute = router.pathname.startsWith('/workspace');
let localToken
if (typeof window !== 'undefined') {
// Perform localStorage action
localToken = localStorage.getItem('token')
}
const isTokenValid = () => {
const token = localStorage.getItem('token');
if (!token) return;
const date = new Date().getTime() / 1000;
const data = jwt.decode(token);
if (!data) return;
return date < data.exp;
};
useEffect(() => {
dispatch(findMe());
if (!isTokenValid()) {
dispatch(logoutUser());
router.push('/login');
}
}, [token, localToken]);
useEffect(() => {
if (!permission || !currentUser) return;
if (!hasPermission(currentUser, permission)) router.push('/error');
}, [currentUser, permission]);
const darkMode = useAppSelector((state) => state.style.darkMode)
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
useEffect(() => {
const handleRouteChangeStart = () => {
setIsAsideMobileExpanded(false)
setIsAsideLgActive(false)
}
router.events.on('routeChangeStart', handleRouteChangeStart)
// If the component is unmounted, unsubscribe
// from the event with the `off` method:
return () => {
router.events.off('routeChangeStart', handleRouteChangeStart)
}
}, [router.events, dispatch])
const layoutAsidePadding = isWorkspaceRoute ? '' : 'xl:pl-60'
const layoutOffsetClass = isWorkspaceRoute ? '' : isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
return (
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
<div
className={`${layoutAsidePadding} ${layoutOffsetClass} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
>
<NavBar
menu={menuNavBar}
className={`${layoutAsidePadding} ${layoutOffsetClass}`}
>
{isWorkspaceRoute ? (
<div className="flex min-w-0 flex-1 items-center justify-between px-4 sm:px-6">
<Link
className="truncate text-xs font-semibold uppercase tracking-[0.3em] text-slate-500 dark:text-slate-300"
href="/workspace"
>
AI Chat Workspace
</Link>
<span className="hidden text-xs uppercase tracking-[0.24em] text-slate-400 md:inline-flex">
Focused workspace
</span>
</div>
) : (
<>
<NavBarItemPlain
display="flex lg:hidden"
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
>
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
</NavBarItemPlain>
<NavBarItemPlain
display="hidden lg:flex xl:hidden"
onClick={() => setIsAsideLgActive(true)}
>
<BaseIcon path={mdiMenu} size="24" />
</NavBarItemPlain>
<NavBarItemPlain useMargin>
<Search />
</NavBarItemPlain>
</>
)}
</NavBar>
{!isWorkspaceRoute && (
<AsideMenu
isAsideMobileExpanded={isAsideMobileExpanded}
isAsideLgActive={isAsideLgActive}
menu={menuAside}
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
)}
{children}
{!isWorkspaceRoute && <FooterBar>Hand-crafted & Made with </FooterBar>}
</div>
</div>
)
}

View File

@ -0,0 +1,251 @@
import React from 'react';
type ChatMarkdownProps = {
content: string;
className?: string;
};
type InlineToken = {
type: 'text' | 'code' | 'bold' | 'italic' | 'link';
value: string;
href?: string;
};
const INLINE_REGEX = /(\[[^\]]+\]\([^\)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g;
const parseInlineTokens = (text: string): InlineToken[] => {
const parts = text.split(INLINE_REGEX).filter(Boolean);
return parts.map((part) => {
if (part.startsWith('`') && part.endsWith('`')) {
return {
type: 'code',
value: part.slice(1, -1),
};
}
if (part.startsWith('**') && part.endsWith('**')) {
return {
type: 'bold',
value: part.slice(2, -2),
};
}
if (part.startsWith('*') && part.endsWith('*')) {
return {
type: 'italic',
value: part.slice(1, -1),
};
}
const linkMatch = part.match(/^\[([^\]]+)\]\(([^\)]+)\)$/);
if (linkMatch) {
return {
type: 'link',
value: linkMatch[1],
href: linkMatch[2],
};
}
return {
type: 'text',
value: part,
};
});
};
const renderInline = (text: string, keyPrefix: string) =>
parseInlineTokens(text).map((token, index) => {
const key = `${keyPrefix}-${index}`;
if (token.type === 'code') {
return (
<code
key={key}
className="rounded-[6px] border border-slate-200 bg-slate-100 px-1.5 py-0.5 font-mono text-[0.95em] text-slate-700 dark:border-white/10 dark:bg-white/10 dark:text-[#CDE4FF]"
>
{token.value}
</code>
);
}
if (token.type === 'bold') {
return (
<strong key={key} className="font-semibold text-slate-900 dark:text-white">
{token.value}
</strong>
);
}
if (token.type === 'italic') {
return (
<em key={key} className="italic text-slate-700 dark:text-slate-100">
{token.value}
</em>
);
}
if (token.type === 'link') {
return (
<a
key={key}
className="text-sky-600 underline underline-offset-4 dark:text-[#7DD3FC]"
href={token.href}
rel="noreferrer"
target="_blank"
>
{token.value}
</a>
);
}
return <React.Fragment key={key}>{token.value}</React.Fragment>;
});
const headingClassNames: Record<string, string> = {
h1: 'text-2xl font-semibold tracking-[-0.03em] text-slate-900 dark:text-white',
h2: 'text-xl font-semibold tracking-[-0.02em] text-slate-900 dark:text-white',
h3: 'text-lg font-semibold text-slate-900 dark:text-white',
};
const isSpecialBlock = (line: string) => {
const trimmed = line.trim();
return (
!trimmed ||
trimmed.startsWith('```') ||
trimmed.startsWith('- ') ||
trimmed.startsWith('* ') ||
trimmed.startsWith('> ') ||
trimmed.startsWith('# ')
);
};
export default function ChatMarkdown({ content, className = '' }: ChatMarkdownProps) {
const lines = content.replace(/\r\n/g, '\n').split('\n');
const blocks: React.ReactNode[] = [];
let index = 0;
while (index < lines.length) {
const currentLine = lines[index];
const trimmed = currentLine.trim();
if (!trimmed) {
index += 1;
continue;
}
if (trimmed.startsWith('```')) {
const language = trimmed.replace(/```/, '').trim();
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !lines[index].trim().startsWith('```')) {
codeLines.push(lines[index]);
index += 1;
}
blocks.push(
<div key={`code-${index}`} className="overflow-hidden rounded-[10px] border border-slate-200 bg-slate-950 dark:border-white/10 dark:bg-[#050816]">
<div className="flex items-center justify-between border-b border-slate-800 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.24em] text-slate-400">
<span>{language || 'code'}</span>
<span>{codeLines.length} lines</span>
</div>
<pre className="overflow-x-auto px-4 py-4 text-sm leading-6 text-[#E2E8F0]">
<code>{codeLines.join('\n')}</code>
</pre>
</div>,
);
index += 1;
continue;
}
if (trimmed.startsWith('# ')) {
blocks.push(
<h1 key={`h1-${index}`} className={headingClassNames.h1}>
{renderInline(trimmed.replace(/^#\s+/, ''), `h1-${index}`)}
</h1>,
);
index += 1;
continue;
}
if (trimmed.startsWith('## ')) {
blocks.push(
<h2 key={`h2-${index}`} className={headingClassNames.h2}>
{renderInline(trimmed.replace(/^##\s+/, ''), `h2-${index}`)}
</h2>,
);
index += 1;
continue;
}
if (trimmed.startsWith('### ')) {
blocks.push(
<h3 key={`h3-${index}`} className={headingClassNames.h3}>
{renderInline(trimmed.replace(/^###\s+/, ''), `h3-${index}`)}
</h3>,
);
index += 1;
continue;
}
if (trimmed.startsWith('> ')) {
const quoteLines: string[] = [];
while (index < lines.length && lines[index].trim().startsWith('> ')) {
quoteLines.push(lines[index].trim().replace(/^>\s+/, ''));
index += 1;
}
blocks.push(
<blockquote
key={`quote-${index}`}
className="rounded-[10px] border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900 dark:border-[#38BDF8]/20 dark:bg-[#0F172A] dark:text-[#CFE8FF]"
>
{renderInline(quoteLines.join(' '), `quote-${index}`)}
</blockquote>,
);
continue;
}
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
const items: string[] = [];
while (
index < lines.length &&
(lines[index].trim().startsWith('- ') || lines[index].trim().startsWith('* '))
) {
items.push(lines[index].trim().replace(/^[-*]\s+/, ''));
index += 1;
}
blocks.push(
<ul key={`list-${index}`} className="space-y-2 pl-5 text-slate-100">
{items.map((item, itemIndex) => (
<li key={`item-${index}-${itemIndex}`} className="list-disc">
{renderInline(item, `list-${index}-${itemIndex}`)}
</li>
))}
</ul>,
);
continue;
}
const paragraphLines: string[] = [];
while (index < lines.length && !isSpecialBlock(lines[index])) {
paragraphLines.push(lines[index].trim());
index += 1;
}
blocks.push(
<p key={`paragraph-${index}`} className="text-[15px] leading-7 text-slate-700 dark:text-slate-100/95">
{renderInline(paragraphLines.join(' '), `paragraph-${index}`)}
</p>,
);
}
return <div className={`space-y-4 ${className}`}>{blocks}</div>;
}

File diff suppressed because it is too large Load Diff

View File

@ -10,13 +10,15 @@ import { useAppSelector } from '../stores/hooks';
type Props = {
menu: MenuNavBarItem[]
className: string
contentClassName?: string
children: ReactNode
}
export default function NavBar({ menu, className = '', children }: Props) {
export default function NavBar({ menu, className = '', contentClassName = containerMaxW, children }: Props) {
const [isMenuNavBarActive, setIsMenuNavBarActive] = useState(false)
const [isScrolled, setIsScrolled] = useState(false);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const hasMenu = menu.length > 0;
useEffect(() => {
const handleScroll = () => {
@ -37,20 +39,24 @@ export default function NavBar({ menu, className = '', children }: Props) {
<nav
className={`${className} top-0 inset-x-0 fixed ${bgColor} h-14 z-30 transition-position w-screen lg:w-auto dark:bg-dark-800`}
>
<div className={`flex lg:items-stretch ${containerMaxW} ${isScrolled && `border-b border-pavitra-400 dark:border-dark-700`}`}>
<div className={`flex lg:items-stretch ${contentClassName} ${isScrolled && `border-b border-pavitra-400 dark:border-dark-700`}`}>
<div className="flex flex-1 items-stretch h-14">{children}</div>
<div className="flex-none items-stretch flex h-14 lg:hidden">
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
<BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size="24" />
</NavBarItemPlain>
</div>
<div
className={`${
isMenuNavBarActive ? 'block' : 'hidden'
} flex items-center max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 left-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`}
>
<NavBarMenuList menu={menu} />
</div>
{hasMenu && (
<>
<div className="flex-none items-stretch flex h-14 lg:hidden">
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
<BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size="24" />
</NavBarItemPlain>
</div>
<div
className={`${
isMenuNavBarActive ? 'block' : 'hidden'
} flex items-center max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 left-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`}
>
<NavBarMenuList menu={menu} />
</div>
</>
)}
</div>
</nav>
)

View File

@ -63,7 +63,7 @@ const renderInline = (text: string, keyPrefix: string) =>
return (
<code
key={key}
className="rounded-md border border-white/10 bg-white/10 px-1.5 py-0.5 font-mono text-[0.95em] text-[#CDE4FF]"
className="rounded-md border border-slate-200 bg-slate-100 px-1.5 py-0.5 font-mono text-[0.95em] text-slate-700 dark:border-white/10 dark:bg-white/10 dark:text-[#CDE4FF]"
>
{token.value}
</code>
@ -72,7 +72,7 @@ const renderInline = (text: string, keyPrefix: string) =>
if (token.type === 'bold') {
return (
<strong key={key} className="font-semibold text-white">
<strong key={key} className="font-semibold text-slate-900 dark:text-white">
{token.value}
</strong>
);
@ -80,7 +80,7 @@ const renderInline = (text: string, keyPrefix: string) =>
if (token.type === 'italic') {
return (
<em key={key} className="italic text-slate-100">
<em key={key} className="italic text-slate-700 dark:text-slate-100">
{token.value}
</em>
);
@ -90,7 +90,7 @@ const renderInline = (text: string, keyPrefix: string) =>
return (
<a
key={key}
className="text-[#7DD3FC] underline underline-offset-4 transition hover:text-[#BAE6FD]"
className="text-sky-600 underline underline-offset-4 transition hover:text-sky-700 dark:text-[#7DD3FC] dark:hover:text-[#BAE6FD]"
href={token.href}
rel="noreferrer"
target="_blank"
@ -104,9 +104,9 @@ const renderInline = (text: string, keyPrefix: string) =>
});
const headingClassNames: Record<string, string> = {
h1: 'text-2xl font-semibold tracking-[-0.03em] text-white',
h2: 'text-xl font-semibold tracking-[-0.02em] text-white',
h3: 'text-lg font-semibold text-white',
h1: 'text-2xl font-semibold tracking-[-0.03em] text-slate-900 dark:text-white',
h2: 'text-xl font-semibold tracking-[-0.02em] text-slate-900 dark:text-white',
h3: 'text-lg font-semibold text-slate-900 dark:text-white',
};
const isSpecialBlock = (line: string) => {
@ -147,8 +147,8 @@ export default function ChatMarkdown({ content, className = '' }: ChatMarkdownPr
}
blocks.push(
<div key={`code-${index}`} className="overflow-hidden rounded-2xl border border-white/10 bg-[#050816]">
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.24em] text-slate-400">
<div key={`code-${index}`} className="overflow-hidden rounded-2xl border border-slate-200 bg-slate-950 dark:border-white/10 dark:bg-[#050816]">
<div className="flex items-center justify-between border-b border-slate-800 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.24em] text-slate-400">
<span>{language || 'code'}</span>
<span>{codeLines.length} lines</span>
</div>
@ -203,7 +203,7 @@ export default function ChatMarkdown({ content, className = '' }: ChatMarkdownPr
blocks.push(
<blockquote
key={`quote-${index}`}
className="rounded-2xl border border-[#38BDF8]/20 bg-[#0F172A] px-4 py-3 text-sm text-[#CFE8FF]"
className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900 dark:border-[#38BDF8]/20 dark:bg-[#0F172A] dark:text-[#CFE8FF]"
>
{renderInline(quoteLines.join(' '), `quote-${index}`)}
</blockquote>,
@ -241,7 +241,7 @@ export default function ChatMarkdown({ content, className = '' }: ChatMarkdownPr
}
blocks.push(
<p key={`paragraph-${index}`} className="text-[15px] leading-7 text-slate-100/95">
<p key={`paragraph-${index}`} className="text-[15px] leading-7 text-slate-700 dark:text-slate-100/95">
{renderInline(paragraphLines.join(' '), `paragraph-${index}`)}
</p>,
);

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +1,95 @@
@layer base {
select:not([multiple]):not([size]) {
appearance: none;
background-color: rgb(255 255 255);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='M4 6L8 10L12 6' stroke='%2394a3b8' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-position: right 0.85rem center;
background-repeat: no-repeat;
background-size: 14px 14px;
@apply rounded-[10px] border border-slate-200 px-3.5 py-2.5 pr-10 text-[13px] leading-5 text-slate-900 shadow-none outline-none ring-0 transition-colors;
}
select:not([multiple]):not([size]):hover {
@apply border-slate-300;
}
select:not([multiple]):not([size]):focus {
@apply border-slate-900 outline-none ring-0;
}
select:not([multiple]):not([size]):disabled {
@apply cursor-not-allowed bg-slate-100 text-slate-400;
}
select option {
@apply bg-white text-slate-900;
}
}
.react-select__control {
@apply dark:bg-dark-800 dark:border-dark-700 !important;
@apply min-h-[42px] rounded-[10px] border border-slate-200 bg-white text-slate-900 shadow-none transition-colors dark:border-dark-700 dark:bg-dark-800 !important;
box-shadow: none !important;
}
.react-select__control:hover {
@apply border-slate-300 dark:border-dark-600 !important;
}
.react-select__control--is-focused {
@apply border-slate-900 dark:border-white !important;
box-shadow: none !important;
}
.react-select__value-container {
@apply px-2 py-0.5 !important;
}
.react-select__single-value {
@apply dark:text-white !important;
@apply text-slate-900 dark:text-white !important;
}
.react-select__placeholder {
@apply text-slate-400 !important;
}
.react-select__indicator-separator {
@apply hidden !important;
}
.react-select__dropdown-indicator,
.react-select__clear-indicator {
@apply text-slate-400 dark:text-slate-300 !important;
}
.react-select__menu {
@apply dark:border-dark-700
@apply overflow-hidden rounded-[10px] border border-slate-200 bg-white shadow-[0_16px_48px_-32px_rgba(15,23,42,0.25)] dark:border-dark-700 dark:bg-dark-800 !important;
}
.react-select__menu-list {
@apply dark:bg-dark-800 dark:border-dark-700 dark:rounded !important;
@apply p-1 dark:bg-dark-800 !important;
}
.react-select__option {
@apply cursor-pointer hover:bg-gray-200 dark:hover:bg-dark-700 !important;
@apply cursor-pointer rounded-[8px] px-3 py-2 text-[13px] text-slate-700 dark:text-slate-100 !important;
}
.react-select__option--is-focused {
@apply dark:bg-dark-800 dark:text-white hover:dark:bg-dark-700 hover:dark:text-white !important;
.react-select__option--is-focused {
@apply bg-slate-100 text-slate-900 dark:bg-dark-700 dark:text-white !important;
}
.react-select__option--is-selected, .react-select__option--is-selected:hover {
@apply dark:bg-dark-600 !important;
.react-select__option--is-selected,
.react-select__option--is-selected:hover {
@apply bg-slate-900 text-white dark:bg-dark-600 !important;
}
.react-select__multi-value {
@apply rounded-[8px] border border-slate-200 bg-slate-50 dark:border-dark-700 dark:bg-dark-700 !important;
}
.react-select__multi-value__label {
@apply text-[12px] text-slate-700 dark:text-slate-100 !important;
}
.react-select__multi-value__remove {
@apply dark:bg-dark-600 dark:text-white hover:dark:bg-red-300 hover:dark:text-red-600 !important;
@apply rounded-r-[8px] text-slate-500 hover:bg-slate-200 hover:text-slate-900 dark:bg-dark-600 dark:text-white hover:dark:bg-red-300 hover:dark:text-red-600 !important;
}

200
frontend/src/index.tsx Normal file
View File

@ -0,0 +1,200 @@
import Head from 'next/head';
import Link from 'next/link';
import type { ReactElement } from 'react';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
const quickPoints = [
'Calm, chat-first workspace',
'Conversation history that stays out of the way',
'Backoffice still included when you need it',
];
const previewThreads = [
{
title: 'Launch prep',
summary: 'Define the MVP checklist and the admin follow-up tasks.',
active: true,
},
{
title: 'API cleanup',
summary: 'Tighten validation and error handling in the route layer.',
},
{
title: 'Spec drafting',
summary: 'Turn rough notes into a short product spec with decisions.',
desktopOnly: true,
},
];
export default function LandingPage() {
return (
<>
<Head>
<title>{getPageTitle('AI Chat Workspace')}</title>
</Head>
<div className="relative min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f8f6f1_0%,#f2f4f8_52%,#f8f5ef_100%)] text-slate-900">
<div className="pointer-events-none absolute inset-0">
<div className="absolute left-[-10rem] top-[-7rem] h-[24rem] w-[24rem] rounded-full bg-[#f7d9c7]/55 blur-3xl" />
<div className="absolute right-[-6rem] top-[7rem] h-[26rem] w-[26rem] rounded-full bg-[#d7defd]/65 blur-3xl" />
<div className="absolute bottom-[-10rem] left-[32%] h-[22rem] w-[22rem] rounded-full bg-[#e6efe6]/55 blur-3xl" />
</div>
<header className="relative z-10 mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-6 lg:px-10">
<Link className="text-xs font-semibold uppercase tracking-[0.34em] text-slate-500" href="/">
AI Chat Workspace
</Link>
<nav className="flex items-center gap-3">
<Link
className="rounded-md border border-white/70 bg-white/70 px-4 py-2 text-sm font-medium text-slate-700 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.3)] backdrop-blur"
href="/login"
>
Sign in
</Link>
<Link
className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-[0_18px_35px_-24px_rgba(15,23,42,0.55)]"
href="/workspace"
>
Open workspace
</Link>
</nav>
</header>
<main className="relative z-10 mx-auto w-full max-w-7xl px-6 pb-16 pt-6 lg:px-10 lg:pb-24 lg:pt-10">
<section className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_460px] lg:items-start xl:grid-cols-[minmax(0,1fr)_540px] xl:items-center">
<div className="max-w-[38rem] xl:max-w-3xl">
<p className="text-xs font-semibold uppercase tracking-[0.34em] text-slate-400">
Chat-first product shell
</p>
<h1 className="mt-6 max-w-4xl text-5xl font-semibold tracking-[-0.075em] text-slate-900 md:text-6xl lg:text-[4.5rem] lg:leading-[0.94] xl:text-[5.2rem]">
A more human workspace for thinking with an AI agent.
</h1>
<p className="mt-6 max-w-2xl text-base leading-8 text-slate-600 md:text-lg">
Start a new chat, reopen older threads, switch agents, and keep work moving without
losing context. The interface stays calm and legible so the conversation can stay
central.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link
className="rounded-md bg-slate-900 px-6 py-3 text-sm font-medium text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.55)]"
href="/workspace"
>
Try the workspace
</Link>
<Link
className="rounded-md border border-white/70 bg-white/70 px-6 py-3 text-sm font-medium text-slate-700 backdrop-blur"
href="/login"
>
Sign in
</Link>
</div>
<div className="mt-10 grid gap-3 sm:max-w-2xl">
{quickPoints.map((point) => (
<div
className="inline-flex w-fit items-center rounded-md border border-white/70 bg-white/60 px-4 py-2 text-sm text-slate-600 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.28)] backdrop-blur"
key={point}
>
{point}
</div>
))}
</div>
</div>
<section className="relative lg:pt-2">
<div className="absolute inset-4 rounded-[16px] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.95),rgba(255,255,255,0.4))] blur-2xl" />
<div className="relative overflow-hidden rounded-[18px] border border-white/80 bg-white/75 shadow-[0_35px_120px_-50px_rgba(15,23,42,0.32)] backdrop-blur">
<div className="border-b border-slate-200/80 px-5 py-3.5 xl:px-6 xl:py-4.5">
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
Workspace preview
</p>
<h2 className="mt-2 max-w-[21rem] text-[1.45rem] font-semibold tracking-[-0.05em] text-slate-900 xl:max-w-sm xl:text-[1.8rem]">
History stays visible, but the active conversation keeps the spotlight.
</h2>
</div>
<div className="grid min-h-[24rem] lg:grid-cols-[170px_minmax(0,1fr)] xl:min-h-[30rem] xl:grid-cols-[205px_minmax(0,1fr)]">
<div className="border-r border-slate-200/80 bg-[#fbfaf7]/90 p-3 xl:p-4">
<button
className="inline-flex w-full items-center justify-center rounded-md bg-slate-900 px-4 py-2.5 text-[13px] font-medium text-white shadow-[0_16px_30px_-22px_rgba(15,23,42,0.6)] xl:text-sm"
type="button"
>
New chat
</button>
<div className="mt-4 space-y-2.5 xl:space-y-3">
{previewThreads.map((thread) => (
<div
className={`rounded-[10px] border px-3 py-3 xl:px-3.5 xl:py-3.5 ${
thread.active
? 'border-slate-900 bg-slate-900 text-white shadow-[0_18px_40px_-28px_rgba(15,23,42,0.6)]'
: 'border-slate-200/90 bg-white/85 text-slate-900'
} ${thread.desktopOnly ? 'hidden xl:block' : ''}`}
key={thread.title}
>
<p className="text-[13px] font-medium xl:text-sm">{thread.title}</p>
<p
className={`mt-2 text-[11px] leading-5 xl:text-xs xl:leading-6 ${
thread.active ? 'text-slate-300' : 'text-slate-500'
}`}
>
{thread.summary}
</p>
</div>
))}
</div>
</div>
<div className="flex flex-col bg-[linear-gradient(180deg,rgba(252,252,253,0.92)_0%,rgba(246,247,250,0.98)_100%)]">
<div className="border-b border-slate-200/80 px-4 py-3.5 xl:px-5 xl:py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
Current conversation
</p>
<h3 className="mt-2 text-[1.1rem] font-semibold tracking-[-0.05em] text-slate-900 xl:text-[1.5rem]">
Draft a launch checklist
</h3>
</div>
<div className="flex-1 space-y-3 px-4 py-3.5 xl:space-y-4 xl:px-5 xl:py-4.5">
<div className="ml-auto max-w-[88%] rounded-[12px] border border-slate-900 bg-slate-900 px-4 py-3.5 text-white shadow-[0_22px_45px_-30px_rgba(15,23,42,0.6)] xl:max-w-[84%] xl:px-4.5 xl:py-4.5">
<p className="text-[11px] uppercase tracking-[0.22em] text-white/55">You</p>
<p className="mt-2.5 text-[13px] leading-6 xl:mt-3 xl:text-[14px] xl:leading-7">
Build a concise launch checklist for the MVP and include the most
important backoffice follow-ups.
</p>
</div>
<div className="max-w-[94%] rounded-[12px] border border-slate-200 bg-white px-4 py-3.5 shadow-[0_16px_35px_-28px_rgba(15,23,42,0.25)] xl:max-w-[92%] xl:px-4.5 xl:py-4.5">
<p className="text-[11px] uppercase tracking-[0.22em] text-slate-400">Assistant</p>
<div className="mt-2.5 space-y-2 text-[13px] leading-6 text-slate-600 xl:mt-3 xl:space-y-2.5 xl:text-[14px] xl:leading-7">
<p className="font-semibold text-slate-900">Launch checklist</p>
<p>- Validate sign-in, new chat, rename, archive, and delete flows.</p>
<p>- Confirm agent selection and conversation persistence.</p>
<p className="hidden xl:block">
- Review visibility of users, conversations, messages, and usage events.
</p>
</div>
</div>
</div>
<div className="border-t border-slate-200/80 px-4 py-3 xl:px-5 xl:py-3.5">
<div className="rounded-[10px] border border-white/90 bg-white px-4 py-2.5 text-[12px] text-slate-500 shadow-[0_14px_35px_-28px_rgba(15,23,42,0.18)] xl:py-3 xl:text-sm">
Ask for a draft, plan, code snippet, or product spec
</div>
</div>
</div>
</div>
</div>
</section>
</section>
</main>
</div>
</>
);
}
LandingPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -1,6 +1,14 @@
import React, { ReactNode, useEffect, useState } from 'react'
import React, { ReactNode, useEffect, useRef, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import {
mdiForwardburger,
mdiBackburger,
mdiMenu,
mdiAccountCircleOutline,
mdiChevronDown,
mdiCogOutline,
mdiLogout,
} from '@mdi/js'
import menuAside from '../menuAside'
import menuNavBar from '../menuNavBar'
import BaseIcon from '../components/BaseIcon'
@ -12,6 +20,8 @@ import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Search from '../components/Search';
import { useRouter } from 'next/router'
import {findMe, logoutUser} from "../stores/authSlice";
import Link from 'next/link';
import ClickOutside from '../components/ClickOutside';
import {hasPermission} from "../helpers/userPermissions";
@ -33,6 +43,8 @@ export default function LayoutAuthenticated({
const router = useRouter()
const { token, currentUser } = useAppSelector((state) => state.auth)
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const isWorkspaceRoute = router.pathname.startsWith('/workspace');
const workspaceNavMenu = isWorkspaceRoute ? [] : menuNavBar;
let localToken
if (typeof window !== 'undefined') {
// Perform localStorage action
@ -68,11 +80,14 @@ export default function LayoutAuthenticated({
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
const [isWorkspaceAccountMenuOpen, setIsWorkspaceAccountMenuOpen] = useState(false)
const workspaceAccountMenuButtonRef = useRef(null)
useEffect(() => {
const handleRouteChangeStart = () => {
setIsAsideMobileExpanded(false)
setIsAsideLgActive(false)
setIsWorkspaceAccountMenuOpen(false)
}
router.events.on('routeChangeStart', handleRouteChangeStart)
@ -85,43 +100,103 @@ export default function LayoutAuthenticated({
}, [router.events, dispatch])
const layoutAsidePadding = 'xl:pl-60'
const layoutAsidePadding = isWorkspaceRoute ? '' : 'xl:pl-60'
const layoutOffsetClass = isWorkspaceRoute ? '' : isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
return (
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
<div
className={`${layoutAsidePadding} ${
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
className={`${layoutAsidePadding} ${layoutOffsetClass} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
>
<NavBar
menu={menuNavBar}
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
menu={workspaceNavMenu}
className={`${layoutAsidePadding} ${layoutOffsetClass}`}
contentClassName={isWorkspaceRoute ? 'w-full' : undefined}
>
<NavBarItemPlain
display="flex lg:hidden"
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
>
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
</NavBarItemPlain>
<NavBarItemPlain
display="hidden lg:flex xl:hidden"
onClick={() => setIsAsideLgActive(true)}
>
<BaseIcon path={mdiMenu} size="24" />
</NavBarItemPlain>
<NavBarItemPlain useMargin>
<Search />
</NavBarItemPlain>
{isWorkspaceRoute ? (
<div className="flex min-w-0 flex-1 items-center justify-between px-4 sm:px-6">
<Link
className="truncate text-xs font-semibold uppercase tracking-[0.3em] text-slate-500 dark:text-slate-300"
href="/workspace"
>
AI Chat Workspace
</Link>
<div className="relative hidden md:block">
<button
className="inline-flex items-center gap-2 rounded-[8px] border border-slate-200 bg-white px-3 py-1.5 text-[12px] text-slate-500"
onClick={() => setIsWorkspaceAccountMenuOpen((previous) => !previous)}
ref={workspaceAccountMenuButtonRef}
type="button"
>
<BaseIcon path={mdiAccountCircleOutline} size="18" />
<span className="max-w-[220px] truncate">
{currentUser?.email || 'Workspace user'}
</span>
<BaseIcon path={mdiChevronDown} size="16" />
</button>
{isWorkspaceAccountMenuOpen && (
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-40 min-w-[220px]">
<ClickOutside
excludedElements={[workspaceAccountMenuButtonRef]}
onClickOutside={() => setIsWorkspaceAccountMenuOpen(false)}
>
<div className="overflow-hidden rounded-[10px] border border-slate-200 bg-white p-1 shadow-[0_16px_48px_-32px_rgba(15,23,42,0.25)]">
<Link
className="flex items-center gap-2 rounded-[8px] px-3 py-2 text-[13px] text-slate-700"
href="/profile"
onClick={() => setIsWorkspaceAccountMenuOpen(false)}
>
<BaseIcon path={mdiCogOutline} size="16" />
Settings
</Link>
<button
className="flex w-full items-center gap-2 rounded-[8px] px-3 py-2 text-left text-[13px] text-slate-700"
onClick={() => {
setIsWorkspaceAccountMenuOpen(false)
dispatch(logoutUser())
router.push('/login')
}}
type="button"
>
<BaseIcon path={mdiLogout} size="16" />
Log out
</button>
</div>
</ClickOutside>
</div>
)}
</div>
</div>
) : (
<>
<NavBarItemPlain
display="flex lg:hidden"
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
>
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
</NavBarItemPlain>
<NavBarItemPlain
display="hidden lg:flex xl:hidden"
onClick={() => setIsAsideLgActive(true)}
>
<BaseIcon path={mdiMenu} size="24" />
</NavBarItemPlain>
<NavBarItemPlain useMargin>
<Search />
</NavBarItemPlain>
</>
)}
</NavBar>
<AsideMenu
isAsideMobileExpanded={isAsideMobileExpanded}
isAsideLgActive={isAsideLgActive}
menu={menuAside}
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
{!isWorkspaceRoute && (
<AsideMenu
isAsideMobileExpanded={isAsideMobileExpanded}
isAsideLgActive={isAsideLgActive}
menu={menuAside}
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
)}
{children}
<FooterBar>Hand-crafted & Made with </FooterBar>
{!isWorkspaceRoute && <FooterBar>Hand-crafted & Made with </FooterBar>}
</div>
</div>
)

224
frontend/src/login.tsx Normal file
View File

@ -0,0 +1,224 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { toast } from 'react-toastify';
import { mdiArrowRight, mdiEye, mdiEyeOff } from '@mdi/js';
import BaseIcon from '../components/BaseIcon';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { findMe, loginUser, resetAction } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
const demoAccounts = [
{
label: 'Admin workspace',
email: 'admin@flatlogic.com',
password: '7fd6af4a',
},
{
label: 'User workspace',
email: 'client@hello.com',
password: 'e92fbaf31efb',
},
];
export default function Login() {
const router = useRouter();
const dispatch = useAppDispatch();
const notify = (type, msg) => toast(msg, { type });
const { currentUser, isFetching, errorMessage, token, notify: notifyState } = useAppSelector(
(state) => state.auth,
);
const [showPassword, setShowPassword] = useState(false);
const [initialValues, setInitialValues] = useState({
email: 'admin@flatlogic.com',
password: '7fd6af4a',
remember: true,
});
useEffect(() => {
if (token) {
dispatch(findMe());
}
}, [token, dispatch]);
useEffect(() => {
if (currentUser?.id) {
router.push('/workspace');
}
}, [currentUser?.id, router]);
useEffect(() => {
if (errorMessage) {
notify('error', errorMessage);
}
}, [errorMessage]);
useEffect(() => {
if (notifyState?.showNotification) {
notify('success', notifyState?.textNotification);
dispatch(resetAction());
}
}, [notifyState?.showNotification, notifyState?.textNotification, dispatch]);
const handleSubmit = async (value) => {
const { remember, ...rest } = value;
await dispatch(loginUser(rest));
};
const applyDemoAccount = (email: string, password: string) => {
setInitialValues({
email,
password,
remember: true,
});
};
return (
<>
<Head>
<title>{getPageTitle('Login')}</title>
</Head>
<div className="min-h-screen bg-[#f5f5f7] px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-7xl overflow-hidden rounded-[16px] border border-slate-200 bg-white shadow-[0_32px_120px_-48px_rgba(15,23,42,0.28)]">
<section className="hidden border-r border-slate-200 bg-[#fafafa] lg:flex lg:w-[46%] lg:flex-col lg:justify-between lg:p-10">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-400">
AI Chat Workspace
</p>
<h1 className="mt-5 text-5xl font-semibold tracking-[-0.06em] text-slate-900">
Sign in to your workspace.
</h1>
<p className="mt-5 max-w-xl text-base leading-8 text-slate-500">
A calm place for long-running chats, structured drafts, code help, and agent-guided
work. Conversation history stays organized on the left, while the main canvas stays
focused on the active thread.
</p>
</div>
<div className="space-y-4">
<div className="rounded-[10px] border border-slate-200 bg-white p-6">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
Why this feels clearer
</p>
<ul className="mt-4 space-y-3 text-sm leading-7 text-slate-600">
<li>One main action: start or continue a conversation.</li>
<li>History is visible, but secondary to the active chat.</li>
<li>Backoffice tools stay available without taking over the product.</li>
</ul>
</div>
<div className="rounded-[10px] border border-slate-200 bg-slate-900 p-6 text-white">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-white/50">
Demo access
</p>
<p className="mt-3 text-sm leading-7 text-slate-200">
Use one of the demo accounts on the right to enter the workspace immediately.
</p>
</div>
</div>
</section>
<section className="flex w-full items-center justify-center px-5 py-8 sm:px-8 lg:w-[54%] lg:px-12">
<div className="w-full max-w-xl">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-400 lg:hidden">
AI Chat Workspace
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-slate-900 sm:text-4xl">
Welcome back
</h2>
<p className="mt-3 text-sm leading-7 text-slate-500">
Sign in to open your workspace, continue previous chats, and start new threads.
</p>
<div className="mt-8 grid gap-3 sm:grid-cols-2">
{demoAccounts.map((account) => (
<button
className="rounded-[10px] border border-slate-200 bg-slate-50 px-4 py-4 text-left"
key={account.email}
onClick={() => applyDemoAccount(account.email, account.password)}
type="button"
>
<p className="text-sm font-medium text-slate-900">{account.label}</p>
<p className="mt-2 text-sm text-slate-500">{account.email}</p>
<p className="mt-1 font-mono text-xs text-slate-400">{account.password}</p>
</button>
))}
</div>
<div className="mt-8 rounded-[12px] border border-slate-200 bg-white p-6">
<Formik initialValues={initialValues} enableReinitialize onSubmit={(values) => handleSubmit(values)}>
<Form className="space-y-5">
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
Email
</label>
<Field
className="w-full rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-slate-900 outline-none focus:border-slate-900"
name="email"
placeholder="you@company.com"
type="email"
/>
</div>
<div className="relative">
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
Password
</label>
<Field
className="w-full rounded-md border border-slate-200 bg-slate-50 px-4 py-3 pr-12 text-slate-900 outline-none focus:border-slate-900"
name="password"
placeholder="Enter your password"
type={showPassword ? 'text' : 'password'}
/>
<button
className="absolute right-4 top-[2.85rem] text-slate-400"
onClick={() => setShowPassword((previous) => !previous)}
type="button"
>
<BaseIcon path={showPassword ? mdiEyeOff : mdiEye} size={20} />
</button>
</div>
<div className="flex flex-col gap-3 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between">
<label className="inline-flex items-center gap-3">
<Field className="rounded border-slate-300" name="remember" type="checkbox" />
<span>Remember me</span>
</label>
<Link className="font-medium text-slate-700" href="/forgot">
Forgot password?
</Link>
</div>
<button
className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-slate-900 px-5 py-3.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={isFetching}
type="submit"
>
{isFetching ? 'Signing in…' : 'Sign in'}
<BaseIcon path={mdiArrowRight} size={18} />
</button>
<p className="text-center text-sm text-slate-500">
Don&apos;t have an account yet?{' '}
<Link className="font-medium text-slate-700" href="/register">
Create one
</Link>
</p>
</Form>
</Formik>
</div>
</div>
</section>
</div>
</div>
</>
);
}
Login.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -1,14 +1,7 @@
import * as icon from '@mdi/js';
import { MenuAsideItem } from './interfaces'
const menuAside: MenuAsideItem[] = [
{
href: '/workspace',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiChatOutline' in icon ? icon['mdiChatOutline' as keyof typeof icon] : icon.mdiTable,
label: 'Workspace',
},
const backofficeMenu: MenuAsideItem[] = [
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
@ -78,11 +71,6 @@ const menuAside: MenuAsideItem[] = [
icon: 'mdiChartTimelineVariant' in icon ? icon['mdiChartTimelineVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_USAGE_EVENTS'
},
{
href: '/profile',
label: 'Settings',
icon: icon.mdiAccountCircle,
},
{
href: '/api-docs',
target: '_blank',
@ -92,4 +80,25 @@ const menuAside: MenuAsideItem[] = [
},
]
const menuAside: MenuAsideItem[] = [
{
href: '/workspace',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiChatOutline' in icon ? icon['mdiChatOutline' as keyof typeof icon] : icon.mdiTable,
label: 'Workspace',
},
{
href: '/profile',
label: 'Settings',
icon: icon.mdiAccountCircle,
},
{
label: 'Admin',
icon: icon.mdiCogOutline,
menu: backofficeMenu,
permissions: 'READ_USERS'
},
]
export default menuAside

View File

@ -88,13 +88,13 @@ const Dashboard = () => {
<>
<Head>
<title>
{getPageTitle('Overview')}
{getPageTitle('Admin')}
</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={icon.mdiChartTimelineVariant}
title='Overview'
title='Admin'
main>
{''}
</SectionTitleLineWithButton>
@ -375,7 +375,7 @@ const Dashboard = () => {
}
Dashboard.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
return <LayoutAuthenticated permission={'READ_USERS'}>{page}</LayoutAuthenticated>
}
export default Dashboard

View File

@ -17,7 +17,7 @@ export default function Error() {
<SectionFullScreen bg="pinkRed">
<CardBox
className="w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12 shadow-2xl"
footer={<BaseButton href="/dashboard" label="Done" color="danger" />}
footer={<BaseButton href="/workspace" label="Open workspace" color="danger" />}
>
<div className="space-y-3">
<h1 className="text-2xl">Unhandled exception</h1>

View File

@ -4,29 +4,27 @@ import type { ReactElement } from 'react';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
const features = [
{
title: 'Focused chat workspace',
description:
'A dedicated AI workspace with conversation history on the left and a distraction-free composer at the bottom.',
},
{
title: 'Markdown-ready replies',
description:
'Assistant responses support headings, bullets, inline code, and fenced code blocks for practical work.',
},
{
title: 'Admin still included',
description:
'The generated admin area remains available for users, agents, conversations, and usage visibility.',
},
const quickPoints = [
'Calm, chat-first workspace',
'Conversation history that stays out of the way',
'Backoffice still included when you need it',
];
const workflow = [
'Sign in and open the workspace',
'Start a new chat or reopen a past conversation',
'Send a message and receive a markdown-friendly assistant draft',
'Rename, archive, or delete chats as your history grows',
const previewThreads = [
{
title: 'Launch prep',
summary: 'Define the MVP checklist and the admin follow-up tasks.',
active: true,
},
{
title: 'API cleanup',
summary: 'Tighten validation and error handling in the route layer.',
},
{
title: 'Spec drafting',
summary: 'Turn rough notes into a short product spec with decisions.',
desktopOnly: true,
},
];
export default function LandingPage() {
@ -36,178 +34,162 @@ export default function LandingPage() {
<title>{getPageTitle('AI Chat Workspace')}</title>
</Head>
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,#1E3A8A_0%,#0F172A_36%,#020617_100%)] text-white">
<header className="mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-6 lg:px-10">
<Link className="text-sm font-semibold uppercase tracking-[0.32em] text-[#7DD3FC]" href="/">
<div className="relative min-h-screen overflow-hidden bg-[linear-gradient(180deg,#f8f6f1_0%,#f2f4f8_52%,#f8f5ef_100%)] text-slate-900">
<div className="pointer-events-none absolute inset-0">
<div className="absolute left-[-10rem] top-[-7rem] h-[24rem] w-[24rem] rounded-full bg-[#f7d9c7]/55 blur-3xl" />
<div className="absolute right-[-6rem] top-[7rem] h-[26rem] w-[26rem] rounded-full bg-[#d7defd]/65 blur-3xl" />
<div className="absolute bottom-[-10rem] left-[32%] h-[22rem] w-[22rem] rounded-full bg-[#e6efe6]/55 blur-3xl" />
</div>
<header className="relative z-10 mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-6 lg:px-10">
<Link className="text-xs font-semibold uppercase tracking-[0.34em] text-slate-500" href="/">
AI Chat Workspace
</Link>
<nav className="flex items-center gap-3">
<Link
className="rounded-full border border-white/10 px-4 py-2 text-sm text-slate-100 transition hover:border-white/20 hover:bg-white/[0.04]"
className="rounded-full border border-white/70 bg-white/70 px-4 py-2 text-sm font-medium text-slate-700 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.3)] backdrop-blur transition hover:bg-white"
href="/login"
>
Login
Sign in
</Link>
<Link
className="rounded-full border border-white/10 px-4 py-2 text-sm text-slate-100 transition hover:border-white/20 hover:bg-white/[0.04]"
href="/register"
className="rounded-full bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-[0_18px_35px_-24px_rgba(15,23,42,0.55)] transition hover:bg-slate-800"
href="/workspace"
>
Sign up
</Link>
<Link
className="rounded-full border border-[#60A5FA]/40 bg-[#2563EB] px-4 py-2 text-sm font-medium text-white shadow-[0_16px_40px_-22px_rgba(37,99,235,0.95)] transition hover:bg-[#1D4ED8]"
href="/dashboard"
>
Admin area
Open workspace
</Link>
</nav>
</header>
<main className="mx-auto grid w-full max-w-7xl gap-12 px-6 pb-16 pt-8 lg:grid-cols-[1.1fr_minmax(0,0.9fr)] lg:px-10 lg:pb-24 lg:pt-12">
<section className="flex flex-col justify-center">
<p className="mb-4 text-xs font-medium uppercase tracking-[0.32em] text-[#7DD3FC]">
Custom SaaS landing · first product slice
</p>
<h1 className="max-w-3xl text-5xl font-semibold tracking-[-0.06em] text-white md:text-6xl lg:text-7xl">
A modern AI agent workspace for focused conversations.
</h1>
<p className="mt-6 max-w-2xl text-base leading-8 text-slate-300 md:text-lg">
The app now leads with a clean chat experience instead of a generic dashboard: open a
workspace, start a conversation, receive markdown-friendly replies, and keep your chat
history organized over time.
</p>
<div className="mt-8 flex flex-wrap items-center gap-4">
<Link
className="rounded-full border border-[#60A5FA]/40 bg-[#2563EB] px-6 py-3 text-sm font-medium text-white shadow-[0_18px_50px_-24px_rgba(37,99,235,0.95)] transition hover:bg-[#1D4ED8]"
href="/workspace"
>
Open workspace
</Link>
<Link
className="rounded-full border border-white/10 px-6 py-3 text-sm font-medium text-slate-100 transition hover:border-white/20 hover:bg-white/[0.04]"
href="/login"
>
Sign in to continue
</Link>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{features.map((feature) => (
<div
className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_18px_50px_-36px_rgba(15,23,42,0.95)]"
key={feature.title}
>
<h2 className="text-lg font-semibold tracking-[-0.03em] text-white">
{feature.title}
</h2>
<p className="mt-3 text-sm leading-7 text-slate-300">{feature.description}</p>
</div>
))}
</div>
</section>
<section className="rounded-[34px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.08),rgba(255,255,255,0.02))] p-4 shadow-[0_36px_90px_-48px_rgba(15,23,42,0.95)] backdrop-blur">
<div className="rounded-[28px] border border-white/10 bg-[#060913] p-4">
<div className="grid min-h-[34rem] gap-4 md:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
<div className="mb-4 rounded-full border border-[#60A5FA]/30 bg-[#0F172A] px-4 py-2 text-xs uppercase tracking-[0.24em] text-[#7DD3FC]">
Conversations
</div>
<div className="space-y-3">
<div className="rounded-[18px] border border-[#60A5FA]/30 bg-[#132142] px-4 py-3">
<p className="text-sm font-medium text-white">Launch prep</p>
<p className="mt-2 text-xs leading-6 text-slate-300">
Drafting the release checklist for the AI workspace MVP.
</p>
</div>
<div className="rounded-[18px] border border-white/10 bg-white/[0.03] px-4 py-3">
<p className="text-sm font-medium text-white">Code review follow-up</p>
<p className="mt-2 text-xs leading-6 text-slate-400">
Asking the assistant to improve a route handler and validation.
</p>
</div>
<div className="rounded-[18px] border border-white/10 bg-white/[0.03] px-4 py-3">
<p className="text-sm font-medium text-white">Settings polish</p>
<p className="mt-2 text-xs leading-6 text-slate-400">
Defining the next iteration for profile and preference updates.
</p>
</div>
</div>
</div>
<div className="flex flex-col rounded-[22px] border border-white/10 bg-[radial-gradient(circle_at_top_left,#18264F_0%,#090D1C_48%,#060913_100%)]">
<div className="border-b border-white/10 px-5 py-4">
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">Workspace preview</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">
Markdown-friendly assistant replies, built for real work.
</h2>
</div>
<div className="flex-1 space-y-4 px-5 py-5">
<div className="ml-auto max-w-[85%] rounded-[24px] border border-[#4F7CFF]/30 bg-[linear-gradient(135deg,#2563EB_0%,#1D4ED8_100%)] px-5 py-4 shadow-[0_18px_50px_-30px_rgba(37,99,235,0.95)]">
<p className="text-[11px] uppercase tracking-[0.24em] text-white/70">You</p>
<p className="mt-3 text-sm leading-7 text-white">
Build a short launch checklist for an AI workspace MVP and include the most
important admin follow-up tasks.
</p>
</div>
<div className="max-w-[90%] rounded-[24px] border border-white/10 bg-white/[0.04] px-5 py-4">
<p className="text-[11px] uppercase tracking-[0.24em] text-slate-400">Assistant</p>
<div className="mt-3 space-y-3 text-sm leading-7 text-slate-100">
<p>### Launch checklist</p>
<p>- Confirm auth and workspace routing</p>
<p>- Validate conversation create / rename / archive / delete flows</p>
<p>- Review admin visibility for users, conversations, agents, and usage</p>
<div className="rounded-2xl border border-white/10 bg-[#050816] p-4 text-xs text-[#CDE4FF]">
<p className="uppercase tracking-[0.24em] text-slate-400">code</p>
<p className="mt-3 font-mono">POST /workspace/conversations/:id/messages</p>
</div>
</div>
</div>
</div>
<div className="border-t border-white/10 px-5 py-4 text-sm text-slate-400">
Designed for productivity on desktop and tablet, with the admin area still linked
from the public site.
</div>
</div>
</div>
</div>
</section>
</main>
<section className="mx-auto w-full max-w-7xl px-6 pb-20 lg:px-10">
<div className="grid gap-6 rounded-[34px] border border-white/10 bg-white/[0.03] p-8 lg:grid-cols-[1fr_auto] lg:items-center lg:p-10">
<div>
<p className="text-xs font-medium uppercase tracking-[0.32em] text-[#7DD3FC]">
How the MVP flows
<main className="relative z-10 mx-auto w-full max-w-7xl px-6 pb-16 pt-6 lg:px-10 lg:pb-24 lg:pt-10">
<section className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_460px] lg:items-start xl:grid-cols-[minmax(0,1fr)_540px] xl:items-center">
<div className="max-w-[38rem] xl:max-w-3xl">
<p className="text-xs font-semibold uppercase tracking-[0.34em] text-slate-400">
Chat-first product shell
</p>
<div className="mt-5 grid gap-3 md:grid-cols-2">
{workflow.map((step) => (
<h1 className="mt-6 max-w-4xl text-5xl font-semibold tracking-[-0.075em] text-slate-900 md:text-6xl lg:text-[4.5rem] lg:leading-[0.94] xl:text-[5.2rem]">
A more human workspace for thinking with an AI agent.
</h1>
<p className="mt-6 max-w-2xl text-base leading-8 text-slate-600 md:text-lg">
Start a new chat, reopen older threads, switch agents, and keep work moving without
losing context. The interface stays calm and legible so the conversation can stay
central.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link
className="rounded-full bg-slate-900 px-6 py-3 text-sm font-medium text-white shadow-[0_18px_40px_-24px_rgba(15,23,42,0.55)] transition hover:bg-slate-800"
href="/workspace"
>
Try the workspace
</Link>
<Link
className="rounded-full border border-white/70 bg-white/70 px-6 py-3 text-sm font-medium text-slate-700 backdrop-blur transition hover:bg-white"
href="/login"
>
Sign in
</Link>
</div>
<div className="mt-10 grid gap-3 sm:max-w-2xl">
{quickPoints.map((point) => (
<div
className="rounded-[24px] border border-white/10 bg-[#091224]/80 px-5 py-4 text-sm leading-7 text-slate-200"
key={step}
className="inline-flex w-fit items-center rounded-full border border-white/70 bg-white/60 px-4 py-2 text-sm text-slate-600 shadow-[0_12px_30px_-24px_rgba(15,23,42,0.28)] backdrop-blur"
key={point}
>
{step}
{point}
</div>
))}
</div>
</div>
<div className="flex flex-col gap-3 lg:w-[240px]">
<Link
className="rounded-full border border-[#60A5FA]/40 bg-[#2563EB] px-6 py-3 text-center text-sm font-medium text-white transition hover:bg-[#1D4ED8]"
href="/workspace"
>
Try the workspace
</Link>
<Link
className="rounded-full border border-white/10 px-6 py-3 text-center text-sm font-medium text-slate-100 transition hover:border-white/20 hover:bg-white/[0.04]"
href="/dashboard"
>
Open admin interface
</Link>
</div>
</div>
</section>
<section className="relative lg:pt-2">
<div className="absolute inset-4 rounded-[30px] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.95),rgba(255,255,255,0.4))] blur-2xl" />
<div className="relative overflow-hidden rounded-[34px] border border-white/80 bg-white/75 shadow-[0_35px_120px_-50px_rgba(15,23,42,0.32)] backdrop-blur">
<div className="border-b border-slate-200/80 px-5 py-3.5 xl:px-6 xl:py-4.5">
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
Workspace preview
</p>
<h2 className="mt-2 max-w-[21rem] text-[1.45rem] font-semibold tracking-[-0.05em] text-slate-900 xl:max-w-sm xl:text-[1.8rem]">
History stays visible, but the active conversation keeps the spotlight.
</h2>
</div>
<div className="grid min-h-[24rem] lg:grid-cols-[170px_minmax(0,1fr)] xl:min-h-[30rem] xl:grid-cols-[205px_minmax(0,1fr)]">
<div className="border-r border-slate-200/80 bg-[#fbfaf7]/90 p-3 xl:p-4">
<button
className="inline-flex w-full items-center justify-center rounded-2xl bg-slate-900 px-4 py-2.5 text-[13px] font-medium text-white shadow-[0_16px_30px_-22px_rgba(15,23,42,0.6)] xl:text-sm"
type="button"
>
New chat
</button>
<div className="mt-4 space-y-2.5 xl:space-y-3">
{previewThreads.map((thread) => (
<div
className={`rounded-[20px] border px-3 py-3 transition xl:px-3.5 xl:py-3.5 ${
thread.active
? 'border-slate-900 bg-slate-900 text-white shadow-[0_18px_40px_-28px_rgba(15,23,42,0.6)]'
: 'border-slate-200/90 bg-white/85 text-slate-900'
} ${thread.desktopOnly ? 'hidden xl:block' : ''}`}
key={thread.title}
>
<p className="text-[13px] font-medium xl:text-sm">{thread.title}</p>
<p
className={`mt-2 text-[11px] leading-5 xl:text-xs xl:leading-6 ${
thread.active ? 'text-slate-300' : 'text-slate-500'
}`}
>
{thread.summary}
</p>
</div>
))}
</div>
</div>
<div className="flex flex-col bg-[linear-gradient(180deg,rgba(252,252,253,0.92)_0%,rgba(246,247,250,0.98)_100%)]">
<div className="border-b border-slate-200/80 px-4 py-3.5 xl:px-5 xl:py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.26em] text-slate-400">
Current conversation
</p>
<h3 className="mt-2 text-[1.1rem] font-semibold tracking-[-0.05em] text-slate-900 xl:text-[1.5rem]">
Draft a launch checklist
</h3>
</div>
<div className="flex-1 space-y-3 px-4 py-3.5 xl:space-y-4 xl:px-5 xl:py-4.5">
<div className="ml-auto max-w-[88%] rounded-[22px] border border-slate-900 bg-slate-900 px-4 py-3.5 text-white shadow-[0_22px_45px_-30px_rgba(15,23,42,0.6)] xl:max-w-[84%] xl:rounded-[24px] xl:px-4.5 xl:py-4.5">
<p className="text-[11px] uppercase tracking-[0.22em] text-white/55">You</p>
<p className="mt-2.5 text-[13px] leading-6 xl:mt-3 xl:text-[14px] xl:leading-7">
Build a concise launch checklist for the MVP and include the most
important backoffice follow-ups.
</p>
</div>
<div className="max-w-[94%] rounded-[22px] border border-slate-200 bg-white px-4 py-3.5 shadow-[0_16px_35px_-28px_rgba(15,23,42,0.25)] xl:max-w-[92%] xl:rounded-[24px] xl:px-4.5 xl:py-4.5">
<p className="text-[11px] uppercase tracking-[0.22em] text-slate-400">Assistant</p>
<div className="mt-2.5 space-y-2 text-[13px] leading-6 text-slate-600 xl:mt-3 xl:space-y-2.5 xl:text-[14px] xl:leading-7">
<p className="font-semibold text-slate-900">Launch checklist</p>
<p>- Validate sign-in, new chat, rename, archive, and delete flows.</p>
<p>- Confirm agent selection and conversation persistence.</p>
<p className="hidden xl:block">
- Review visibility of users, conversations, messages, and usage events.
</p>
</div>
</div>
</div>
<div className="border-t border-slate-200/80 px-4 py-3 xl:px-5 xl:py-3.5">
<div className="rounded-[20px] border border-white/90 bg-white px-4 py-2.5 text-[12px] text-slate-500 shadow-[0_14px_35px_-28px_rgba(15,23,42,0.18)] xl:rounded-[22px] xl:py-3 xl:text-sm">
Ask for a draft, plan, code snippet, or product spec
</div>
</div>
</div>
</div>
</div>
</section>
</section>
</main>
</div>
</>
);

View File

@ -1,273 +1,221 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import BaseIcon from "../components/BaseIcon";
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import { Field, Form, Formik } from 'formik';
import FormField from '../components/FormField';
import FormCheckRadio from '../components/FormCheckRadio';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { toast } from 'react-toastify';
import { mdiArrowRight, mdiEye, mdiEyeOff } from '@mdi/js';
import BaseIcon from '../components/BaseIcon';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { findMe, loginUser, resetAction } from '../stores/authSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import Link from 'next/link';
import {toast, ToastContainer} from "react-toastify";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
const demoAccounts = [
{
label: 'Admin workspace',
email: 'admin@flatlogic.com',
password: '7fd6af4a',
},
{
label: 'User workspace',
email: 'client@hello.com',
password: 'e92fbaf31efb',
},
];
export default function Login() {
const router = useRouter();
const dispatch = useAppDispatch();
const textColor = useAppSelector((state) => state.style.linkColor);
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const notify = (type, msg) => toast(msg, { type });
const [ illustrationImage, setIllustrationImage ] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const [showPassword, setShowPassword] = useState(false);
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
const { currentUser, isFetching, errorMessage, token, notify: notifyState } = useAppSelector(
(state) => state.auth,
);
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
const [showPassword, setShowPassword] = useState(false);
const [initialValues, setInitialValues] = useState({
email: 'admin@flatlogic.com',
password: '7fd6af4a',
remember: true })
remember: true,
});
const title = 'AI Chat Workspace'
// Fetch Pexels image/video
useEffect( () => {
async function fetchData() {
const image = await getPexelsImage()
const video = await getPexelsVideo()
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
// Fetch user data
useEffect(() => {
if (token) {
dispatch(findMe());
}
}, [token, dispatch]);
// Redirect to dashboard if user is logged in
useEffect(() => {
if (currentUser?.id) {
router.push('/workspace');
}
}, [currentUser?.id, router]);
// Show error message if there is one
useEffect(() => {
if (errorMessage){
notify('error', errorMessage)
if (errorMessage) {
notify('error', errorMessage);
}
}, [errorMessage]);
}, [errorMessage])
// Show notification if there is one
useEffect(() => {
if (notifyState?.showNotification) {
notify('success', notifyState?.textNotification)
dispatch(resetAction());
}
}, [notifyState?.showNotification])
const togglePasswordVisibility = () => {
setShowPassword(!showPassword);
};
if (notifyState?.showNotification) {
notify('success', notifyState?.textNotification);
dispatch(resetAction());
}
}, [notifyState?.showNotification, notifyState?.textNotification, dispatch]);
const handleSubmit = async (value) => {
const {remember, ...rest} = value
const { remember, ...rest } = value;
await dispatch(loginUser(rest));
};
const setLogin = (target: HTMLElement) => {
setInitialValues(prev => ({
...prev,
email : target.innerText.trim(),
password: target.dataset.password ?? '',
}));
};
const imageBlock = (image) => (
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
style={{
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}>
<div className="flex justify-center w-full bg-blue-300/20">
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
by {image?.photographer} on Pexels</a>
</div>
</div>
)
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video.user.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
const applyDemoAccount = (email: string, password: string) => {
setInitialValues({
email,
password,
remember: true,
});
};
return (
<div style={contentPosition === 'background' ? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
} : {}}>
<Head>
<title>{getPageTitle('Login')}</title>
</Head>
<>
<Head>
<title>{getPageTitle('Login')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
<h2 className="text-4xl font-semibold my-4">{title}</h2>
<div className='flex flex-row text-gray-500 justify-between'>
<div>
<p className='mb-2'>Use{' '}
<code className={`cursor-pointer ${textColor} `}
data-password="7fd6af4a"
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>7fd6af4a</code>{' / '}
to login as Admin</p>
<p>Use <code
className={`cursor-pointer ${textColor} `}
data-password="e92fbaf31efb"
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
<code className={`${textColor}`}>e92fbaf31efb</code>{' / '}
to login as User</p>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
path={mdiInformation}
/>
</div>
</div>
</CardBox>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<Formik
initialValues={initialValues}
enableReinitialize
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField
label='Login'
help='Please enter your login'>
<Field name='email' />
</FormField>
<div className='relative'>
<FormField
label='Password'
help='Please enter your password'>
<Field name='password' type={showPassword ? 'text' : 'password'} />
</FormField>
<div
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
onClick={togglePasswordVisibility}
>
<BaseIcon
className='text-gray-500 hover:text-gray-700'
size={20}
path={showPassword ? mdiEyeOff : mdiEye}
/>
</div>
</div>
<div className={'flex justify-between'}>
<FormCheckRadio type='checkbox' label='Remember'>
<Field type='checkbox' name='remember' />
</FormCheckRadio>
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
Forgot password?
</Link>
</div>
<BaseDivider />
<BaseButtons>
<BaseButton
className={'w-full'}
type='submit'
label={isFetching ? 'Loading...' : 'Login'}
color='info'
disabled={isFetching}
/>
</BaseButtons>
<br />
<p className={'text-center'}>
Dont have an account yet?{' '}
<Link className={`${textColor}`} href={'/register'}>
New Account
</Link>
</p>
</Form>
</Formik>
</CardBox>
<div className="min-h-screen bg-[#f5f5f7] px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-7xl overflow-hidden rounded-[32px] border border-slate-200 bg-white shadow-[0_32px_120px_-48px_rgba(15,23,42,0.28)]">
<section className="hidden border-r border-slate-200 bg-[#fafafa] lg:flex lg:w-[46%] lg:flex-col lg:justify-between lg:p-10">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-400">
AI Chat Workspace
</p>
<h1 className="mt-5 text-5xl font-semibold tracking-[-0.06em] text-slate-900">
Sign in to your workspace.
</h1>
<p className="mt-5 max-w-xl text-base leading-8 text-slate-500">
A calm place for long-running chats, structured drafts, code help, and agent-guided
work. Conversation history stays organized on the left, while the main canvas stays
focused on the active thread.
</p>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
<div className="space-y-4">
<div className="rounded-[28px] border border-slate-200 bg-white p-6">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
Why this feels clearer
</p>
<ul className="mt-4 space-y-3 text-sm leading-7 text-slate-600">
<li>One main action: start or continue a conversation.</li>
<li>History is visible, but secondary to the active chat.</li>
<li>Backoffice tools stay available without taking over the product.</li>
</ul>
</div>
<div className="rounded-[28px] border border-slate-200 bg-slate-900 p-6 text-white">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-white/50">
Demo access
</p>
<p className="mt-3 text-sm leading-7 text-slate-200">
Use one of the demo accounts on the right to enter the workspace immediately.
</p>
</div>
</div>
</section>
<section className="flex w-full items-center justify-center px-5 py-8 sm:px-8 lg:w-[54%] lg:px-12">
<div className="w-full max-w-xl">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-400 lg:hidden">
AI Chat Workspace
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-slate-900 sm:text-4xl">
Welcome back
</h2>
<p className="mt-3 text-sm leading-7 text-slate-500">
Sign in to open your workspace, continue previous chats, and start new threads.
</p>
<div className="mt-8 grid gap-3 sm:grid-cols-2">
{demoAccounts.map((account) => (
<button
className="rounded-[24px] border border-slate-200 bg-slate-50 px-4 py-4 text-left transition hover:bg-slate-100"
key={account.email}
onClick={() => applyDemoAccount(account.email, account.password)}
type="button"
>
<p className="text-sm font-medium text-slate-900">{account.label}</p>
<p className="mt-2 text-sm text-slate-500">{account.email}</p>
<p className="mt-1 font-mono text-xs text-slate-400">{account.password}</p>
</button>
))}
</div>
<div className="mt-8 rounded-[28px] border border-slate-200 bg-white p-6">
<Formik initialValues={initialValues} enableReinitialize onSubmit={(values) => handleSubmit(values)}>
<Form className="space-y-5">
<div>
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
Email
</label>
<Field
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-900 outline-none transition focus:border-slate-900"
name="email"
placeholder="you@company.com"
type="email"
/>
</div>
<div className="relative">
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
Password
</label>
<Field
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 pr-12 text-slate-900 outline-none transition focus:border-slate-900"
name="password"
placeholder="Enter your password"
type={showPassword ? 'text' : 'password'}
/>
<button
className="absolute right-4 top-[2.85rem] text-slate-400 transition hover:text-slate-700"
onClick={() => setShowPassword((previous) => !previous)}
type="button"
>
<BaseIcon path={showPassword ? mdiEyeOff : mdiEye} size={20} />
</button>
</div>
<div className="flex flex-col gap-3 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between">
<label className="inline-flex items-center gap-3">
<Field className="rounded border-slate-300" name="remember" type="checkbox" />
<span>Remember me</span>
</label>
<Link className="font-medium text-slate-700 transition hover:text-slate-900" href="/forgot">
Forgot password?
</Link>
</div>
<button
className="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-slate-900 px-5 py-3.5 text-sm font-medium text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isFetching}
type="submit"
>
{isFetching ? 'Signing in…' : 'Sign in'}
<BaseIcon path={mdiArrowRight} size={18} />
</button>
<p className="text-center text-sm text-slate-500">
Don&apos;t have an account yet?{' '}
<Link className="font-medium text-slate-700 transition hover:text-slate-900" href="/register">
Create one
</Link>
</p>
</Form>
</Formik>
</div>
</div>
</section>
</div>
<ToastContainer />
</div>
</>
);
}

File diff suppressed because one or more lines are too long