diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5184b4a..0803c8c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,6 +64,8 @@ jobs: NEXT_PUBLIC_FARO_URL=${{ vars.NEXT_PUBLIC_FARO_URL }} NEXT_PUBLIC_FARO_APP_VERSION=${{ vars.NEXT_PUBLIC_FARO_APP_VERSION }} NEXT_PUBLIC_APP_ENV=${{ vars.NEXT_PUBLIC_APP_ENV }} + NEXT_PUBLIC_FARO_APP_NAME=${{ vars.NEXT_PUBLIC_FARO_APP_NAME }} + NEXT_PUBLIC_FARO_APP_NAMESPACE=${{ vars.NEXT_PUBLIC_FARO_APP_NAMESPACE }} SKIP_ENV_VALIDATION=true push: true tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db3318f..418e50d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,5 +52,11 @@ jobs: env: SKIP_ENV_VALIDATION: true NODE_ENV: production + NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }} + NEXT_PUBLIC_FARO_URL: ${{ vars.NEXT_PUBLIC_FARO_URL }} + NEXT_PUBLIC_FARO_APP_VERSION: ${{ vars.NEXT_PUBLIC_FARO_APP_VERSION }} + NEXT_PUBLIC_APP_ENV: ${{ vars.NEXT_PUBLIC_APP_ENV }} + NEXT_PUBLIC_FARO_APP_NAME: ${{ vars.NEXT_PUBLIC_FARO_APP_NAME }} + NEXT_PUBLIC_FARO_APP_NAMESPACE: ${{ vars.NEXT_PUBLIC_FARO_APP_NAMESPACE }} # - name: Build Storybook # run: pnpm build-storybook diff --git a/app/(protected)/page.tsx b/app/(protected)/page.tsx index f2d5f3f..284e44f 100644 --- a/app/(protected)/page.tsx +++ b/app/(protected)/page.tsx @@ -1 +1 @@ -export { ProfilePage as default } from 'pages/profile'; +export { MePage as default } from 'pages/profile'; diff --git a/app/(protected)/profile/layout.tsx b/app/(protected)/profile/layout.tsx new file mode 100644 index 0000000..8f89916 --- /dev/null +++ b/app/(protected)/profile/layout.tsx @@ -0,0 +1,14 @@ +import { PageLayout } from 'app/layouts/PageLayout'; +import { TabsNav } from 'pages/profile'; + +export default function ProfileLayout({ children }: { children: React.ReactNode }) { + return ( + } + > + {children} + + ); +} diff --git a/app/(protected)/profile/me/page.tsx b/app/(protected)/profile/me/page.tsx new file mode 100644 index 0000000..284e44f --- /dev/null +++ b/app/(protected)/profile/me/page.tsx @@ -0,0 +1 @@ +export { MePage as default } from 'pages/profile'; diff --git a/app/(protected)/profile/notifications/page.tsx b/app/(protected)/profile/notifications/page.tsx new file mode 100644 index 0000000..4344000 --- /dev/null +++ b/app/(protected)/profile/notifications/page.tsx @@ -0,0 +1 @@ +export { NotificationsPage as default } from 'pages/profile'; diff --git a/app/(protected)/profile/page.tsx b/app/(protected)/profile/page.tsx index f2d5f3f..dda1fcf 100644 --- a/app/(protected)/profile/page.tsx +++ b/app/(protected)/profile/page.tsx @@ -1 +1,6 @@ -export { ProfilePage as default } from 'pages/profile'; +import { redirect } from 'next/navigation'; +import { routes } from 'shared/config'; + +export default function ProfilePage() { + redirect(routes.profile.me()); +} diff --git a/app/(protected)/profile/security/page.tsx b/app/(protected)/profile/security/page.tsx new file mode 100644 index 0000000..dc4ccd9 --- /dev/null +++ b/app/(protected)/profile/security/page.tsx @@ -0,0 +1 @@ +export { SecurityPage as default } from 'pages/profile'; diff --git a/app/(protected)/projects/page.tsx b/app/(protected)/projects/page.tsx deleted file mode 100644 index e50f0df..0000000 --- a/app/(protected)/projects/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProjectsPage as default } from 'pages/projects'; diff --git a/app/(protected)/tasks/page.tsx b/app/(protected)/tasks/page.tsx deleted file mode 100644 index 2af2e5a..0000000 --- a/app/(protected)/tasks/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { TasksPage as default } from 'pages/tasks'; diff --git a/app/(protected)/team/invites/page.tsx b/app/(protected)/team/invites/page.tsx new file mode 100644 index 0000000..560fb30 --- /dev/null +++ b/app/(protected)/team/invites/page.tsx @@ -0,0 +1 @@ +export { InvitesPage as default } from 'pages/team'; diff --git a/app/(protected)/team/layout.tsx b/app/(protected)/team/layout.tsx new file mode 100644 index 0000000..a8ee106 --- /dev/null +++ b/app/(protected)/team/layout.tsx @@ -0,0 +1,16 @@ +import { TabsNav } from 'pages/team'; +import { PageLayout } from 'app/layouts/PageLayout'; +import { Badge } from 'shared/ui'; + +export default function TeamLayout({ children }: { children: React.ReactNode }) { + return ( + 8 участников} + nav={} + > + {children} + + ); +} diff --git a/app/(protected)/team/members/page.tsx b/app/(protected)/team/members/page.tsx new file mode 100644 index 0000000..74be9f9 --- /dev/null +++ b/app/(protected)/team/members/page.tsx @@ -0,0 +1 @@ +export { MembersPage as default } from 'pages/team'; diff --git a/app/(protected)/team/page.tsx b/app/(protected)/team/page.tsx new file mode 100644 index 0000000..178826b --- /dev/null +++ b/app/(protected)/team/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation'; +import { routes } from 'shared/config'; + +export default function TeamPage() { + redirect(routes.team.members()); +} diff --git a/app/(protected)/team/roles/page.tsx b/app/(protected)/team/roles/page.tsx new file mode 100644 index 0000000..f81d7e5 --- /dev/null +++ b/app/(protected)/team/roles/page.tsx @@ -0,0 +1 @@ +export { RolesPage as default } from 'pages/team'; diff --git a/app/(protected)/team/settings/page.tsx b/app/(protected)/team/settings/page.tsx new file mode 100644 index 0000000..420eb16 --- /dev/null +++ b/app/(protected)/team/settings/page.tsx @@ -0,0 +1 @@ +export { Settings as default } from 'pages/team'; diff --git a/eslint.config.mjs b/eslint.config.mjs index 5b30e07..96e8d1a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -27,7 +27,7 @@ const eslintConfig = defineConfig([ { '**/{page,layout,loading,error,not-found,template,default,route}.{jsx,tsx}': 'NEXT_JS_PAGE_ROUTER_FILENAME_CASE', - '**/!({page,layout,loading,error,not-found,template,default,route}).{jsx,tsx}': + '**/!({page,layout,loading,error,not-found,template,default,route,index,*.stories}).{jsx,tsx}': 'PASCAL_CASE', '**/*{Error,Type,Types,Interface,Props,Dto,Response,Request,Contract,Contracts}.ts': 'PASCAL_CASE', @@ -35,6 +35,8 @@ const eslintConfig = defineConfig([ 'KEBAB_CASE', '**/*.{js,mjs,cjs,mts,cts}': 'KEBAB_CASE', '**/use*.{ts,tsx}': 'CAMEL_CASE', + '**/*.stories.{jsx,tsx}': 'KEBAB_CASE', + '**/index.tsx': 'KEBAB_CASE', }, { ignoreMiddleExtensions: true, diff --git a/proxy.ts b/proxy.ts index 4ce29c5..1d245a2 100644 --- a/proxy.ts +++ b/proxy.ts @@ -6,7 +6,7 @@ import { trace } from '@opentelemetry/api'; const REFRESH_COOKIE = 'refresh'; -const PROTECTED_PREFIXES = [routes.profile(), routes.projects(), routes.tasks()]; +const PROTECTED_PREFIXES = [routes.profile.root(), routes.team.root()]; const PUBLIC_ONLY_ROUTES = [routes.auth.signin(), routes.auth.signup()]; function startsWithOneOf(pathname: string, prefixes: string[]) { @@ -26,7 +26,7 @@ export function proxy(req: NextRequest) { } if (isPublicOnly && hasRefreshCookie) { - return NextResponse.redirect(new URL(routes.profile(), req.url)); + return NextResponse.redirect(new URL(routes.profile.root(), req.url)); } const response = NextResponse.next(); diff --git a/src/app/layouts/PageLayout.tsx b/src/app/layouts/PageLayout.tsx new file mode 100644 index 0000000..28f5750 --- /dev/null +++ b/src/app/layouts/PageLayout.tsx @@ -0,0 +1 @@ +export { PageLayout } from 'widgets/page-layout'; diff --git a/src/app/styles/animations.scss b/src/app/styles/animations.scss index 4966f20..0c64494 100644 --- a/src/app/styles/animations.scss +++ b/src/app/styles/animations.scss @@ -3,6 +3,7 @@ --animate-fade-destructive-input: fadeDestructiveInput 0.7s ease-in-out; --animate-fade-in: fadeIn 0.7s ease-in-out forwards; --animate-fade-out: fadeOut 0.7s ease-in-out forwards; + --animate-slide-in-up: slideInUp 0.2s ease-in-out forwards; @keyframes headShake { 0% { @@ -53,4 +54,15 @@ opacity: 0; } } + + @keyframes slideInUp { + from { + transform: translate3d(0, 100%, 0); + visibility: visible; + } + + to { + transform: translate3d(0, 0, 0); + } + } } diff --git a/src/app/styles/global.css b/src/app/styles/global.css index 5515d55..ae7823f 100644 --- a/src/app/styles/global.css +++ b/src/app/styles/global.css @@ -47,74 +47,74 @@ } :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --radius: 0.5rem; + --background: hsl(0 0% 100%); + --foreground: hsl(222 47% 11%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(222 47% 11%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(222 47% 11%); + --primary: hsl(207 100% 52%); + --primary-foreground: hsl(0 0% 100%); + --secondary: hsl(210 40% 96.1%); + --secondary-foreground: hsl(222.2 47.4% 11.2%); + --muted: hsl(210 40% 96.1%); + --muted-foreground: hsl(215.4 16.3% 46.9%); + --accent: hsl(210 40% 96.1%); + --accent-foreground: hsl(222.2 47.4% 11.2%); + --destructive: hsl(0 84.2% 60.2%); + --border: hsl(214.3 31.8% 91.4%); + --input: hsl(214.3 31.8% 91.4%); + --ring: hsl(207 100% 52%); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --sidebar: hsl(0 0% 98%); + --sidebar-foreground: hsl(240 5.3% 26.1%); + --sidebar-primary: hsl(240 5.9% 10%); + --sidebar-primary-foreground: hsl(0 0% 98%); + --sidebar-accent: hsl(240 4.8% 95.9%); + --sidebar-accent-foreground: hsl(240 5.9% 10%); + --sidebar-border: hsl(220 13% 91%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); --link: oklch(1 0 89.876 / 0); --link-foreground: oklch(54.65% 0.246 262.87); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); + --background: hsl(222.2 84% 4.9%); + --foreground: hsl(210 40% 98%); + --card: hsl(222.2 84% 4.9%); + --card-foreground: hsl(210 40% 98%); + --popover: hsl(222.2 84% 4.9%); + --popover-foreground: hsl(210 40% 98%); + --primary: hsl(210 40% 98%); + --primary-foreground: hsl(222.2 47.4% 11.2%); + --secondary: hsl(217.2 32.6% 17.5%); + --secondary-foreground: hsl(210 40% 98%); + --muted: hsl(217.2 32.6% 17.5%); + --muted-foreground: hsl(215 20.2% 65.1%); + --accent: hsl(217.2 32.6% 17.5%); + --accent-foreground: hsl(210 40% 98%); + --destructive: hsl(0 62.8% 30.6%); + --border: hsl(217.2 32.6% 17.5%); + --input: hsl(217.2 32.6% 17.5%); + --ring: hsl(212.7 26.8% 83.9%); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --sidebar: hsl(240 5.9% 10%); + --sidebar-foreground: hsl(240 4.8% 95.9%); + --sidebar-primary: hsl(224.3 76.3% 48%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(240 3.7% 15.9%); + --sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --sidebar-border: hsl(240 3.7% 15.9%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); --link: oklch(1 0 89.876 / 0); --link-foreground: oklch(54.65% 0.246 262.87); } @@ -126,4 +126,7 @@ body { @apply bg-background text-foreground; } + .input-max-w { + @apply md:max-w-[380px]; + } } diff --git a/src/entities/file/api/http.ts b/src/entities/file/api/http.ts new file mode 100644 index 0000000..d38b4ae --- /dev/null +++ b/src/entities/file/api/http.ts @@ -0,0 +1,25 @@ +import { api } from 'shared/api'; +import { UploadFileData, UploadResponse } from '../model/types'; +import { UploadResponse as UploadResponseSchema } from '../model/schemas'; + +export class UploadHttp { + static uploadFile(data: UploadFileData): Promise { + const formData = new FormData(); + + formData.append('file', data.file); + // INCLUDED: SEE AT SWAGGER DOCS TO CONTEXT AND PROPS TOO + formData.append('context', data.context); + + return api({ + url: '/upload', + method: 'POST', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + contracts: { + response: UploadResponseSchema, + }, + }); + } +} diff --git a/src/entities/file/index.ts b/src/entities/file/index.ts new file mode 100644 index 0000000..6965323 --- /dev/null +++ b/src/entities/file/index.ts @@ -0,0 +1,3 @@ +export * as SFile from './model/schemas'; +export * as TFile from './model/types'; +export { UploadHttp } from './api/http'; diff --git a/src/entities/file/model/schemas.ts b/src/entities/file/model/schemas.ts new file mode 100644 index 0000000..077dcb8 --- /dev/null +++ b/src/entities/file/model/schemas.ts @@ -0,0 +1,3 @@ +import { GlobalSuccess } from 'shared/api'; + +export const UploadResponse = GlobalSuccess; diff --git a/src/entities/file/model/types.ts b/src/entities/file/model/types.ts new file mode 100644 index 0000000..c6da511 --- /dev/null +++ b/src/entities/file/model/types.ts @@ -0,0 +1,9 @@ +import { z } from 'zod/v4'; +import { UploadResponse } from './schemas'; + +export type UploadResponse = z.infer; + +export type UploadFileData = { + file: File; + context: string; //TODO: typify +}; diff --git a/src/entities/team/api/http.ts b/src/entities/team/api/http.ts new file mode 100644 index 0000000..de37977 --- /dev/null +++ b/src/entities/team/api/http.ts @@ -0,0 +1,268 @@ +import { api } from 'shared/api'; +import * as STeam from '../model/schemas'; +import * as TTeam from '../model/types'; + +export class TeamHttp { + static createTeam(data: TTeam.CreateTeamBody) { + return api({ + url: '/teams', + method: 'POST', + data, + contracts: { + body: STeam.CreateTeamBody, + response: STeam.ActionResponse, + }, + }); + } + + static checkSlug(slug: string, signal?: AbortSignal) { + return api({ + url: `/teams/check-slug/${slug}`, + method: 'GET', + contracts: { + response: STeam.CheckSlugResponse, + }, + signal, + }); + } + + static getTeam(slug: string, signal?: AbortSignal) { + return api({ + url: `/teams/${slug}`, + method: 'GET', + contracts: { + response: STeam.TeamDetailsResponse, + }, + signal, + }); + } + + static updateTeam(slug: string, data: TTeam.UpdateTeamBody) { + return api({ + url: `/teams/${slug}`, + method: 'PATCH', + data, + contracts: { + body: STeam.UpdateTeamBody, + response: STeam.ActionResponse, + }, + }); + } + + static removeTeam(slug: string) { + return api({ + url: `/teams/${slug}`, + method: 'DELETE', + contracts: { + response: STeam.ActionResponse, + }, + }); + } + + static getInvitations(slug: string, signal?: AbortSignal) { + return api({ + url: `/teams/${slug}/invitations`, + method: 'GET', + contracts: { + response: STeam.TeamInvitationResponse.array(), + }, + signal, + }); + } + + static getInvitation(slug: string, code: string, signal?: AbortSignal) { + return api({ + url: `/teams/${slug}/invitations/${code}`, + method: 'GET', + contracts: { + response: STeam.TeamInvitationResponse, + }, + signal, + }); + } + + static inviteMember(slug: string, data: TTeam.InviteMemberBody) { + return api({ + url: `/teams/${slug}/invitations`, + method: 'POST', + data, + contracts: { + body: STeam.InviteMemberBody, + response: STeam.ActionResponse, + }, + }); + } + + static acceptInvitation(slug: string, code: string) { + return api({ + url: `/teams/${slug}/invitations/${code}/accept`, + method: 'POST', + contracts: { + response: STeam.ActionResponse, + }, + }); + } + + static updateInvitation(slug: string, code: string, data: TTeam.UpdateInvitationBody) { + return api({ + url: `/teams/${slug}/invitations/${code}`, + method: 'PATCH', + data, + contracts: { + body: STeam.UpdateInvitationBody, + response: STeam.TeamInvitationResponse, + }, + }); + } + + static removeInvitation(slug: string, code: string) { + return api({ + url: `/teams/${slug}/invitations/${code}`, + method: 'DELETE', + contracts: { + response: STeam.ActionResponse, + }, + }); + } + + static getMembers(slug: string, signal?: AbortSignal) { + return api({ + url: `/teams/${slug}/members`, + method: 'GET', + contracts: { + response: STeam.TeamMemberResponse.array(), + }, + signal, + }); + } + + static updateMember(slug: string, userId: string, data: TTeam.UpdateMemberBody) { + return api({ + url: `/teams/${slug}/members/${userId}`, + method: 'PATCH', + data, + contracts: { + body: STeam.UpdateMemberBody, + response: STeam.ActionResponse, + }, + }); + } + + static removeMember(slug: string, userId: string) { + return api({ + url: `/teams/${slug}/members/${userId}`, + method: 'DELETE', + contracts: { + response: STeam.ActionResponse, + }, + }); + } + + static syncTags(slug: string, data: TTeam.SyncTagsBody) { + return api({ + url: `/teams/${slug}/tags`, + method: 'PUT', + data, + contracts: { + body: STeam.SyncTagsBody, + response: STeam.ActionResponse, + }, + }); + } + + static updateBanner(slug: string, file: File) { + const formData = new FormData(); + formData.append('file', file); + + return api({ + url: `/teams/${slug}/banner`, + method: 'PATCH', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + contracts: { + response: STeam.FileUploadResponse, + }, + }); + } + + static getProjects(slug: string, signal?: AbortSignal) { + return api({ + url: `/teams/${slug}/projects`, + method: 'GET', + contracts: { + response: STeam.ProjectListResponse, + }, + signal, + }); + } + + static getProject(slug: string, id: string, token?: string, signal?: AbortSignal) { + return api({ + url: `/teams/${slug}/projects/${id}`, + method: 'GET', + params: token ? { token } : undefined, + contracts: { + response: STeam.ProjectDetailResponse, + }, + signal, + }); + } + + static createProject(slug: string, data: TTeam.CreateProjectBody) { + return api({ + url: `/teams/${slug}/projects`, + method: 'POST', + data, + contracts: { + body: STeam.CreateProjectBody, + response: STeam.CreateProjectResponse, + }, + }); + } + + static updateProject(slug: string, id: string, data: TTeam.UpdateProjectBody) { + return api({ + url: `/teams/${slug}/projects/${id}`, + method: 'PATCH', + data, + contracts: { + body: STeam.UpdateProjectBody, + response: STeam.ActionResponse, + }, + }); + } + + static removeProject(slug: string, id: string) { + return api({ + url: `/teams/${slug}/projects/${id}`, + method: 'DELETE', + contracts: { + response: STeam.ActionResponse, + }, + }); + } + + static archiveProject(slug: string, id: string) { + return api({ + url: `/teams/${slug}/projects/${id}/archive`, + method: 'POST', + contracts: { + response: STeam.ActionResponse, + }, + }); + } + + static createProjectShareToken(slug: string, id: string, data: TTeam.CreateShareTokenBody) { + return api({ + url: `/teams/${slug}/projects/${id}/share`, + method: 'POST', + data, + contracts: { + body: STeam.CreateShareTokenBody, + response: STeam.ActionResponse, + }, + }); + } +} diff --git a/src/entities/team/api/queries.ts b/src/entities/team/api/queries.ts new file mode 100644 index 0000000..2a5b3f8 --- /dev/null +++ b/src/entities/team/api/queries.ts @@ -0,0 +1,68 @@ +import { queryOptions } from '@tanstack/react-query'; +import { teamFabricKeys } from '../model/const'; +import { TeamHttp } from './http'; + +export class TeamQueries { + static getTeam(slug: string) { + return queryOptions({ + queryKey: teamFabricKeys.bySlug(slug), + queryFn: async ({ signal }) => TeamHttp.getTeam(slug, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static checkSlug(slug: string) { + return queryOptions({ + queryKey: teamFabricKeys.checkSlug(slug), + queryFn: async ({ signal }) => TeamHttp.checkSlug(slug, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static getInvitation(slug: string, code: string) { + return queryOptions({ + queryKey: teamFabricKeys.invitation(slug, code), + queryFn: async ({ signal }) => TeamHttp.getInvitation(slug, code, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static getInvitations(slug: string) { + return queryOptions({ + queryKey: teamFabricKeys.invitations(slug), + queryFn: async ({ signal }) => TeamHttp.getInvitations(slug, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static getMembers(slug: string) { + return queryOptions({ + queryKey: teamFabricKeys.members(slug), + queryFn: async ({ signal }) => TeamHttp.getMembers(slug, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static getProjects(slug: string) { + return queryOptions({ + queryKey: teamFabricKeys.projects(slug), + queryFn: async ({ signal }) => TeamHttp.getProjects(slug, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static getProject(slug: string, id: string, token?: string) { + return queryOptions({ + queryKey: [...teamFabricKeys.project(slug, id), token ?? null], + queryFn: async ({ signal }) => TeamHttp.getProject(slug, id, token, signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } +} diff --git a/src/entities/team/index.ts b/src/entities/team/index.ts new file mode 100644 index 0000000..ca8b6fa --- /dev/null +++ b/src/entities/team/index.ts @@ -0,0 +1,5 @@ +export * as STeam from './model/schemas'; +export * as TTeam from './model/types'; +export { TeamHttp } from './api/http'; +export { TeamQueries } from './api/queries'; +export { teamFabricKeys } from './model/const'; diff --git a/src/entities/team/model/const.ts b/src/entities/team/model/const.ts new file mode 100644 index 0000000..d08a8d8 --- /dev/null +++ b/src/entities/team/model/const.ts @@ -0,0 +1,11 @@ +import { createEntityKeys } from 'shared/lib/utils'; + +export const teamFabricKeys = createEntityKeys('team', { + bySlug: (slug: string) => ['teams', slug], + checkSlug: (slug: string) => ['teams', 'check-slug', slug], + invitations: (slug: string) => ['teams', slug, 'invitations'], + invitation: (slug: string, code: string) => ['teams', slug, 'invitations', code], + members: (slug: string) => ['teams', slug, 'members'], + projects: (slug: string) => ['teams', slug, 'projects'], + project: (slug: string, id: string) => ['teams', slug, 'projects', id], +}); diff --git a/src/entities/team/model/schemas.ts b/src/entities/team/model/schemas.ts new file mode 100644 index 0000000..f3602e4 --- /dev/null +++ b/src/entities/team/model/schemas.ts @@ -0,0 +1,206 @@ +import { z } from 'zod/v4'; +import { GlobalSuccess } from 'shared/api'; + +export const TeamRole = z.enum([ + 'owner', + 'admin', // управление юзерами, настройками + 'lead', // управление проектами + 'moderator', // чистка контента/сообщений + 'member', // обычный работяга + 'viewer', // просто смотрит +]); +export const MemberStatus = z.enum([ + 'active', // Полноценный участник + 'banned', // Заблокирован не может вернуться по инвайту + 'inactive', // Доступ закрыт, но запись сохранена +]); + +export const CreateTeamBody = z.object({ + name: z.string().min(2).max(100), + description: z.string().min(10).max(500), + slug: z.string().optional(), + tags: z + .array(z.string()) + .optional() + .superRefine((items, ctx) => { + if (!items) return; + const hasDuplicates = new Set(items.map((item) => item.toLowerCase())).size !== items.length; + if (hasDuplicates) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Теги в списке не должны повторяться (регистр не важен)', + }); + } + }), +}); + +export const UpdateTeamBody = CreateTeamBody.partial().refine( + (data) => Object.keys(data).length > 0, + { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + } +); + +export const CheckSlugResponse = z.object({ + available: z.boolean(), + message: z.string().optional(), +}); + +export const TeamDetailsResponse = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + description: z.string().nullable(), + avatarUrl: z.string().nullable(), + coverUrl: z.string().nullable(), + ownerId: z.string().nullable(), + createdAt: z.iso.datetime({}), + updatedAt: z.iso.datetime({}), + deletedAt: z.iso.datetime({}).nullable(), +}); + +export const TeamInvitationResponse = z.object({ + code: z.string(), + teamId: z.string(), + teamName: z.string(), + teamAvatar: z.string().nullable(), + email: z.email(), + role: TeamRole, + inviterId: z.string(), + inviterName: z.string(), + createdAt: z.iso.datetime({}), + expiresAt: z.iso.datetime({}), +}); + +export const InviteMemberBody = z.object({ + email: z.email(), + role: TeamRole.default('member'), +}); + +export const UpdateInvitationBody = z.object({ + role: TeamRole, +}); + +export const TeamMemberResponse = z.object({ + id: z.string(), + role: TeamRole, + status: MemberStatus, + fullName: z.string(), + firstName: z.string(), + lastName: z.string(), + avatarUrl: z.url().nullable(), + initials: z.string().max(2), + joinedAt: z.iso.datetime({}), +}); + +export const UpdateMemberBody = z + .object({ + role: TeamRole.optional(), + status: MemberStatus.optional(), + }) + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }); + +export const SyncTagsBody = z.object({ + tags: z + .array(z.string()) + .min(1, 'Список тегов не может быть пустым') + .max(15, 'Нельзя добавить более 15 тегов за раз') + .superRefine((items, ctx) => { + const hasDuplicates = new Set(items.map((item) => item.toLowerCase())).size !== items.length; + if (hasDuplicates) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Теги в списке не должны повторяться (регистр не важен)', + }); + } + }), +}); + +export const FileUploadResponse = z.object({ + success: z.boolean(), + url: z.string(), + message: z.string().optional(), +}); + +export const ActionResponse = GlobalSuccess; + +export const CreateProjectBody = z.object({ + name: z.string().min(1).max(100), + key: z + .string() + .min(2) + .max(10) + .regex(/^[A-Z0-9]+$/), + description: z.string().max(2000).optional().nullable(), + icon: z.string().optional().nullable(), + color: z + .string() + .regex(/^#[A-Fa-f0-9]{6}$/) + .optional(), + visibility: z.enum(['public', 'private']).default('public'), +}); + +export const UpdateProjectBody = CreateProjectBody.extend({ + status: z.enum(['active', 'archived']).optional(), + isPublic: z.boolean().optional(), +}) + .partial() + .refine((data) => Object.keys(data).length > 0, { + error: 'Необходимо передать хотя бы одно поле для обновления', + abort: true, + }); + +export const CreateProjectResponse = GlobalSuccess.extend({ + projectId: z.string(), +}); + +export const CreateShareTokenBody = z.object({ + ttl: z.iso.datetime({}).optional().nullable(), +}); + +export const ProjectListItemResponse = z.object({ + id: z.string(), + key: z.string(), + name: z.string(), + status: z.enum(['active', 'archived', 'template']), + color: z.string(), + icon: z.string().nullable(), + createdAt: z.iso.datetime({}), + canEdit: z.boolean(), +}); + +export const ProjectListResponse = z.object({ + team: z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + role: z.string(), + }), + items: ProjectListItemResponse.array(), + meta: z.object({ total: z.number() }), +}); + +export const ProjectDetailResponse = z.object({ + id: z.string(), + key: z.string(), + name: z.string(), + status: z.enum(['active', 'archived', 'template']), + description: z.string().nullable(), + visuals: z.object({ color: z.string(), icon: z.string().nullable() }), + meta: z.object({ + taskSequence: z.number(), + createdAt: z.iso.datetime({}), + updatedAt: z.iso.datetime({}), + }), + access: z.object({ + visibility: z.enum(['public', 'private']), + canEdit: z.boolean(), + canDelete: z.boolean(), + shareUrl: z.string().nullable(), + }), + settings: z.record(z.string(), z.unknown()), +}); diff --git a/src/entities/team/model/types.ts b/src/entities/team/model/types.ts new file mode 100644 index 0000000..55cfafe --- /dev/null +++ b/src/entities/team/model/types.ts @@ -0,0 +1,29 @@ +import { z } from 'zod/v4'; +import * as STeam from './schemas'; + +export type TeamRole = z.infer; +export type MemberStatus = z.infer; + +export type CreateTeamBody = z.infer; +export type UpdateTeamBody = z.infer; +export type CheckSlugResponse = z.infer; +export type TeamDetailsResponse = z.infer; + +export type TeamInvitationResponse = z.infer; +export type TeamMemberResponse = z.infer; + +export type InviteMemberBody = z.infer; +export type UpdateInvitationBody = z.infer; +export type UpdateMemberBody = z.infer; +export type SyncTagsBody = z.infer; +export type FileUploadResponse = z.infer; +export type ActionResponse = z.infer; + +export type CreateProjectBody = z.infer; +export type UpdateProjectBody = z.infer; +export type CreateProjectResponse = z.infer; +export type CreateShareTokenBody = z.infer; + +export type ProjectListItemResponse = z.infer; +export type ProjectListResponse = z.infer; +export type ProjectDetailResponse = z.infer; diff --git a/src/entities/user/api/http.ts b/src/entities/user/api/http.ts index 30960bd..4a78460 100644 --- a/src/entities/user/api/http.ts +++ b/src/entities/user/api/http.ts @@ -24,24 +24,6 @@ export class UserHttp { }); } - static updateAvatar(file: File) { - const formData = new FormData(); - - formData.append('file', file); - - return api({ - url: '/users/me/avatar', - method: 'POST', - data: formData, - headers: { - 'Content-Type': 'multipart/form-data', - }, - contracts: { - response: SUser.AvatarUpdateResponse, - }, - }); - } - static updateNotificationsConfig(data: TUser.NotificationsUpdateBody) { return api({ url: '/users/me/notifications', @@ -65,4 +47,26 @@ export class UserHttp { }, }); } + + static getMyTeams(signal?: AbortSignal) { + return api({ + url: '/users/me/teams', + method: 'GET', + contracts: { + response: SUser.UserTeamResponse.array(), + }, + signal, + }); + } + + static getMyInvites(signal?: AbortSignal) { + return api({ + url: '/users/me/invites', + method: 'GET', + contracts: { + response: SUser.UserInviteResponse.array(), + }, + signal, + }); + } } diff --git a/src/entities/user/api/queries.ts b/src/entities/user/api/queries.ts index d6d2b56..82dd123 100644 --- a/src/entities/user/api/queries.ts +++ b/src/entities/user/api/queries.ts @@ -20,4 +20,22 @@ export class UserQueries { refetchOnMount: false, }); } + + static getMyTeams() { + return queryOptions({ + queryKey: userFabricKeys.myTeams(), + queryFn: async ({ signal }) => UserHttp.getMyTeams(signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } + + static getMyInvites() { + return queryOptions({ + queryKey: userFabricKeys.myInvites(), + queryFn: async ({ signal }) => UserHttp.getMyInvites(signal), + staleTime: 60_000, + refetchOnMount: false, + }); + } } diff --git a/src/entities/user/model/const.ts b/src/entities/user/model/const.ts index 145fd66..79da620 100644 --- a/src/entities/user/model/const.ts +++ b/src/entities/user/model/const.ts @@ -3,4 +3,6 @@ import { createEntityKeys } from 'shared/lib/utils'; export const userFabricKeys = createEntityKeys('user', { me: () => ['users', 'me'], meActivity: () => ['users', 'me', 'activity'], + myTeams: () => ['users', 'me', 'teams'], + myInvites: () => ['users', 'me', 'invites'], }); diff --git a/src/entities/user/model/schemas.ts b/src/entities/user/model/schemas.ts index 7847f74..d4504da 100644 --- a/src/entities/user/model/schemas.ts +++ b/src/entities/user/model/schemas.ts @@ -1,6 +1,15 @@ import { z } from 'zod/v4'; import { GlobalSuccess } from 'shared/api'; +export const UserAvatarSchema = z + .object({ + small: z.string().url(), + medium: z.string().url(), + large: z.string().url(), + original: z.string().url(), + }) + .nullish(); + export const UserResponse = z.object({ id: z.string(), email: z.email(), @@ -9,7 +18,7 @@ export const UserResponse = z.object({ lastName: z.string(), middleName: z.string().nullable(), bio: z.string().nullable(), - avatarUrl: z.url().nullable(), + avatar: UserAvatarSchema, timezone: z.string(), language: z.string(), createdAt: z.iso.datetime({}), @@ -32,8 +41,6 @@ export const UserResponse = z.object({ }), }); -export const AvatarUpdateResponse = GlobalSuccess; - export const NotificationsUpdateBody = z.object({ email: z .object({ @@ -62,3 +69,31 @@ export const ProfileUpdateBody = z.object({ }); export const ProfileUpdateResponse = GlobalSuccess; + +export const TeamPermissions = z.object({ + canEdit: z.boolean(), + canDelete: z.boolean(), + canManageMembers: z.boolean(), + canInvite: z.boolean(), + isOwner: z.boolean(), +}); + +export const UserTeamResponse = z.object({ + id: z.string().uuid(), + name: z.string(), + slug: z.string(), + description: z.string().nullable(), + avatarUrl: z.string().nullable(), + role: z.string(), + joinedAt: z.iso.datetime({}), + permissions: TeamPermissions, +}); + +export const UserInviteResponse = z.object({ + code: z.string(), + teamName: z.string(), + teamAvatar: z.string().nullable(), + role: z.string(), + inviterName: z.string(), + expiresAt: z.iso.datetime({}), +}); diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts index b4c7e5e..e798783 100644 --- a/src/entities/user/model/types.ts +++ b/src/entities/user/model/types.ts @@ -2,8 +2,9 @@ import { z } from 'zod/v4'; import * as SUser from './schemas'; export type UserResponse = z.infer; -export type AvatarUpdateResponse = z.infer; export type NotificationsUpdateBody = z.infer; export type NotificationsUpdateResponse = z.infer; export type ProfileUpdateBody = z.infer; export type ProfileUpdateResponse = z.infer; +export type UserTeamResponse = z.infer; +export type UserInviteResponse = z.infer; diff --git a/src/features/auth/sign-out/index.ts b/src/features/auth/sign-out/index.ts new file mode 100644 index 0000000..28e2f31 --- /dev/null +++ b/src/features/auth/sign-out/index.ts @@ -0,0 +1 @@ +export { SignOut } from './ui/SignOut'; diff --git a/src/pages/profile/model/useSignOut.ts b/src/features/auth/sign-out/model/useSignOut.ts similarity index 53% rename from src/pages/profile/model/useSignOut.ts rename to src/features/auth/sign-out/model/useSignOut.ts index e149ee3..8b2699a 100644 --- a/src/pages/profile/model/useSignOut.ts +++ b/src/features/auth/sign-out/model/useSignOut.ts @@ -1,5 +1,9 @@ import { type DefaultError, useMutation } from '@tanstack/react-query'; import { AuthHttp, TAuth } from 'entities/auth'; +import { useRouter } from 'next/navigation'; +import { AccessToken } from 'shared/api'; +import { routes } from 'shared/config'; +import { toast } from 'sonner'; interface UseSignOutProps { onSuccess?: (res: TAuth.SignoutResponse) => void; @@ -7,13 +11,23 @@ interface UseSignOutProps { } export function useSignOut({ onSuccess, onError }: UseSignOutProps = {}) { + const router = useRouter(); + return useMutation, DefaultError, void>({ mutationFn: AuthHttp.signout, onError: (err) => { onError?.(err); }, - onSuccess: (res) => { + onSuccess: async (res, _v, _m, { client }) => { onSuccess?.(res); + + await client.cancelQueries(); + client.clear(); + + AccessToken.clear(); + + toast.success(res.message || 'Вы вышли из аккаунта'); + router.replace(routes.auth.signin()); }, }); } diff --git a/src/features/auth/sign-out/ui/SignOut.tsx b/src/features/auth/sign-out/ui/SignOut.tsx new file mode 100644 index 0000000..0aa2c48 --- /dev/null +++ b/src/features/auth/sign-out/ui/SignOut.tsx @@ -0,0 +1,23 @@ +import { LogOut } from 'lucide-react'; +import type { ComponentProps } from 'react'; +import { Button } from 'shared/ui'; +import { useSignOut } from '../model/useSignOut'; + +function SignOut(props: Omit, 'children'>) { + const signoutMutation = useSignOut(); + + return ( + signoutMutation.mutate()} + disabled={signoutMutation.isPending} + {...props} + > + Выйти + + + ); +} + +export { SignOut }; diff --git a/src/features/upload-avatar/index.ts b/src/features/upload-avatar/index.ts new file mode 100644 index 0000000..ebb79b0 --- /dev/null +++ b/src/features/upload-avatar/index.ts @@ -0,0 +1 @@ +export { UploadAvatar } from './ui/UploadAvatar'; diff --git a/src/features/upload-avatar/model/useUploadAvatar.ts b/src/features/upload-avatar/model/useUploadAvatar.ts new file mode 100644 index 0000000..58030f1 --- /dev/null +++ b/src/features/upload-avatar/model/useUploadAvatar.ts @@ -0,0 +1,22 @@ +import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { TFile, UploadHttp } from 'entities/file'; + +export type UseUploadFileOptions = Omit< + UseMutationOptions, + 'mutationFn' +>; + +export function useUploadAvatar({ onSuccess, onError, ...props }: UseUploadFileOptions = {}) { + return useMutation({ + mutationFn: UploadHttp.uploadFile, + onError: (...args) => { + onError?.(...args); + }, + onSuccess: async (res, ...args) => { + onSuccess?.(res, ...args); + toast.success(res.message ?? 'Аватар успешно загружен'); + }, + ...props, + }); +} diff --git a/src/pages/profile/ui/ProfileAvatarSection.tsx b/src/features/upload-avatar/ui/UploadAvatar.tsx similarity index 52% rename from src/pages/profile/ui/ProfileAvatarSection.tsx rename to src/features/upload-avatar/ui/UploadAvatar.tsx index abca32e..7614bda 100644 --- a/src/pages/profile/ui/ProfileAvatarSection.tsx +++ b/src/features/upload-avatar/ui/UploadAvatar.tsx @@ -1,32 +1,30 @@ import { Pencil } from 'lucide-react'; -import { type ChangeEvent, useRef } from 'react'; +import { type ChangeEvent, ComponentProps, useRef } from 'react'; import { Avatar, AvatarFallback, AvatarImage, Button } from 'shared/ui'; -import { toast } from 'sonner'; -import { useUpdateAvatar } from '../model/useUpdateAvatar'; +import { classNames } from 'shared/lib/utils'; +import { useUploadAvatar, UseUploadFileOptions } from '../model/useUploadAvatar'; +import { TFile } from 'entities/file'; -interface ProfileAvatarSectionProps { - avatarUrl: string | null; - fullName: string; - firstName: string; - lastName: string; - onUploaded: () => Promise; +interface UploadAvatarProps { + className?: string; + avatar: string | null; + alt: string; + fallback?: ComponentProps; + context: TFile.UploadFileData['context']; + mutationOptions?: UseUploadFileOptions; } -function ProfileAvatarSection({ - avatarUrl, - fullName, - firstName, - lastName, - onUploaded, -}: ProfileAvatarSectionProps) { +function UploadAvatar({ + className, + avatar, + alt, + context, + fallback = {}, + mutationOptions, +}: UploadAvatarProps) { const fileInputRef = useRef(null); - const uploadAvatarMutation = useUpdateAvatar({ - onSuccess: async () => { - toast.success('Аватар обновлён'); - await onUploaded(); - }, - }); + const uploadAvatarMutation = useUploadAvatar(mutationOptions); const handleAvatarPick = () => { fileInputRef.current?.click(); @@ -38,17 +36,15 @@ function ProfileAvatarSection({ return; } - uploadAvatarMutation.mutate(file); + uploadAvatarMutation.mutate({ file, context }); event.target.value = ''; }; return ( - + - - - {`${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase()} - + + []; + push: NotificationItem<'push'>[]; +} = { + email: [ + { + key: 'mentions', + label: 'Упоминания', + ariaLabel: 'Email уведомления об упоминаниях', + }, + { + key: 'daily_summary', + label: 'Ежедневная сводка', + ariaLabel: 'Email ежедневная сводка', + }, + { + key: 'task_assigned', + label: 'Назначение задач', + ariaLabel: 'Email уведомления о назначении задач', + }, + ], + push: [ + { + key: 'reminders', + label: 'Напоминания', + ariaLabel: 'Push напоминания', + }, + { + key: 'task_assigned', + label: 'Назначение задач', + ariaLabel: 'Push уведомления о назначении задач', + }, + ], +}; diff --git a/src/pages/profile/model/types.ts b/src/pages/profile/model/types.ts index f74058f..c394947 100644 --- a/src/pages/profile/model/types.ts +++ b/src/pages/profile/model/types.ts @@ -1,4 +1,14 @@ import { z } from 'zod/v4'; import * as SProfile from './schemas'; +import { TUser } from 'entities/user'; + +export type Notifications = TUser.UserResponse['notifications']; +export type NotificationChannel = keyof Notifications; + +export type NotificationItem = { + key: keyof Notifications[TChannel]; + label: string; + ariaLabel: string; +}; export type ProfileFormValues = z.infer; diff --git a/src/pages/profile/model/useUpdateAvatar.ts b/src/pages/profile/model/useUpdateAvatar.ts deleted file mode 100644 index a16897d..0000000 --- a/src/pages/profile/model/useUpdateAvatar.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { type DefaultError, useMutation } from '@tanstack/react-query'; -import { TUser, UserHttp } from 'entities/user'; - -interface UseUpdateAvatarProps { - onSuccess?: (file: File, res: TUser.AvatarUpdateResponse) => void; - onError?: (err: Error) => void; -} - -export function useUpdateAvatar({ onSuccess, onError }: UseUpdateAvatarProps = {}) { - return useMutation, DefaultError, File>({ - mutationFn: UserHttp.updateAvatar, - onError: (err) => { - onError?.(err); - }, - onSuccess: (res, file) => { - onSuccess?.(file, res); - }, - }); -} diff --git a/src/pages/profile/model/useUpdateNotifications.ts b/src/pages/profile/model/useUpdateNotifications.ts index 7a01b92..72443d4 100644 --- a/src/pages/profile/model/useUpdateNotifications.ts +++ b/src/pages/profile/model/useUpdateNotifications.ts @@ -1,5 +1,6 @@ import { type DefaultError, useMutation } from '@tanstack/react-query'; -import { TUser, UserHttp } from 'entities/user'; +import { TUser, userFabricKeys, UserHttp } from 'entities/user'; +import { toast } from 'sonner'; interface UseUpdateNotificationsProps { onSuccess?: (body: TUser.NotificationsUpdateBody, res: TUser.NotificationsUpdateResponse) => void; @@ -18,6 +19,9 @@ export function useUpdateNotifications({ onSuccess, onError }: UseUpdateNotifica }, onSuccess: (res, body) => { onSuccess?.(body, res); + toast.success('Настройки уведомлений обновлены'); }, + onSettled: async (_d, _e, _v, _m, { client }) => + client.invalidateQueries({ queryKey: userFabricKeys.me() }), }); } diff --git a/src/pages/profile/model/useUpdateProfile.ts b/src/pages/profile/model/useUpdateProfile.ts index e88f1f4..f0282c0 100644 --- a/src/pages/profile/model/useUpdateProfile.ts +++ b/src/pages/profile/model/useUpdateProfile.ts @@ -1,5 +1,6 @@ import { type DefaultError, useMutation } from '@tanstack/react-query'; -import { TUser, UserHttp } from 'entities/user'; +import { type TUser, userFabricKeys, UserHttp } from 'entities/user'; +import { toast } from 'sonner'; interface UseUpdateProfileProps { onSuccess?: (body: TUser.ProfileUpdateBody, res: TUser.ProfileUpdateResponse) => void; @@ -12,8 +13,11 @@ export function useUpdateProfile({ onSuccess, onError }: UseUpdateProfileProps = onError: (err) => { onError?.(err); }, - onSuccess: (res, body) => { + onSuccess: async (res, body) => { onSuccess?.(body, res); + toast.success(res.message ?? 'Профиль успешно обновлен'); }, + onSettled: async (_d, _e, _v, _m, { client }) => + client.invalidateQueries({ queryKey: userFabricKeys.me() }), }); } diff --git a/src/pages/profile/ui/ProfileIdentityCard.tsx b/src/pages/profile/ui/ProfileIdentityCard.tsx deleted file mode 100644 index 4ad65f5..0000000 --- a/src/pages/profile/ui/ProfileIdentityCard.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { ComponentProps, useEffect } from 'react'; -import { - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Field, - FieldError, - FieldGroup, - Input, - Textarea, -} from 'shared/ui'; -import { toast } from 'sonner'; -import { Controller, useForm, useWatch } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { ProfileAvatarSection } from './ProfileAvatarSection'; -import { TUser, UserQueries } from 'entities/user'; -import { ProfileForm as ProfileFormSchema } from '../model/schemas'; -import type { ProfileFormValues } from '../model/types'; -import { useUpdateProfile } from '../model/useUpdateProfile'; -import { useQuery } from '@tanstack/react-query'; - -function ProfileIdentityCard(props: Omit, 'children'>) { - const query = useQuery(UserQueries.getMe()); - const profile = query.data?.profile; - const email = query.data?.email; - - const form = useForm({ - resolver: zodResolver(ProfileFormSchema), - defaultValues: { - firstName: '', - lastName: '', - bio: '', - }, - }); - const formValues = useWatch({ control: form.control }); - - const updateProfileMutation = useUpdateProfile({ - onSuccess: async () => { - toast.success('Профиль обновлён'); - await query.refetch(); - }, - }); - - useEffect(() => { - if (!profile) { - return; - } - - form.reset({ - firstName: profile.firstName, - lastName: profile.lastName, - bio: profile.bio || '', - }); - }, [form, profile]); - - if (!profile || !email) { - return ( - - - Профиль - Данные профиля пока недоступны. - - - ); - } - - const fullName = `${profile.firstName} ${profile.lastName}`; - const profileFormKeys: Array = ['firstName', 'lastName', 'bio']; - const hasProfileChanges = profileFormKeys.some( - (key) => (formValues[key] ?? '').trim() !== (profile[key] ?? '').trim() - ); - - const onSubmit = (data: ProfileFormValues) => { - const body: TUser.ProfileUpdateBody = { - firstName: data.firstName.trim(), - lastName: data.lastName.trim(), - bio: data.bio ? data.bio.trim() : '', - }; - - updateProfileMutation.mutate(body); - }; - - return ( - - - Профиль - Основная информация аккаунта и настройки языка. - - - - - { - await query.refetch(); - }} - /> - - {fullName} - {email} - - {profile.bio?.trim() || 'Добавьте информацию о себе в профиле'} - - - - - - - - Имя и фамилия - - - ( - - - {fieldState.invalid && } - - )} - /> - ( - - - {fieldState.invalid && } - - )} - /> - - - О себе - - ( - - - {fieldState.invalid && } - - )} - /> - - - - - Сохранить изменения - - - - - - - Часовой пояс - - {profile.timezone} - - - - Язык - - {profile.language} - - - - Профиль обновлен - - {profile.updatedAt} - - - - - - ); -} - -export { ProfileIdentityCard }; diff --git a/src/pages/profile/ui/ProfileNotificationsCard.tsx b/src/pages/profile/ui/ProfileNotificationsCard.tsx deleted file mode 100644 index 20cf8ab..0000000 --- a/src/pages/profile/ui/ProfileNotificationsCard.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { ComponentProps, useEffect, useId, useReducer } from 'react'; -import { useQueuedDebouncedMutation } from 'shared/lib/hooks'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Label, - Separator, - Switch, -} from 'shared/ui'; -import { toast } from 'sonner'; -import { TUser, UserQueries } from 'entities/user'; -import { useUpdateNotifications } from '../model/useUpdateNotifications'; -import { useQuery } from '@tanstack/react-query'; - -const SAVE_DEBOUNCE_MS = 500; - -type Notifications = TUser.UserResponse['notifications']; -type NotificationsState = Notifications | null; -type NotificationChannel = keyof Pick; - -type NotificationsAction = { - type: 'set'; - payload: NotificationsState; -}; - -const notificationsReducer = ( - _state: NotificationsState, - action: NotificationsAction -): NotificationsState => { - switch (action.type) { - case 'set': - return action.payload; - default: - return _state; - } -}; - -const areNotificationsEqual = (left: Notifications, right: Notifications) => - JSON.stringify(left) === JSON.stringify(right); - -interface SwitchItemProps extends ComponentProps { - wrapperClassName?: string; - label?: string; -} - -function SwitchItem({ label, ...props }: SwitchItemProps) { - const id = useId(); - - return ( - - {label ? ( - - {label} - - ) : null} - - - ); -} - -function ProfileNotificationsCard() { - const query = useQuery(UserQueries.getMe()); - const notifications = query.data?.notifications; - const [localNotifications, dispatchLocalNotifications] = useReducer( - notificationsReducer, - notifications ?? null - ); - const sendSettings = useUpdateNotifications({ - onSuccess: () => { - toast.success('Настройки уведомлений обновлены'); - query.refetch(); - }, - }); - - const { enqueueMutation, syncPersistedValue } = useQueuedDebouncedMutation({ - delayMs: SAVE_DEBOUNCE_MS, - initialPersistedValue: notifications ?? null, - isEqual: areNotificationsEqual, - mutationFn: sendSettings.mutateAsync, - }); - - useEffect(() => { - if (!notifications) { - return; - } - - syncPersistedValue(notifications); - dispatchLocalNotifications({ type: 'set', payload: notifications }); - }, [notifications, syncPersistedValue]); - - const handleToggle = ( - channel: TChannel, - key: keyof Notifications[TChannel], - checked: boolean - ) => { - if (!localNotifications) { - return; - } - - const nextNotifications: Notifications = { - ...localNotifications, - [channel]: { - ...localNotifications[channel], - [key]: checked, - }, - } as Notifications; - - dispatchLocalNotifications({ type: 'set', payload: nextNotifications }); - enqueueMutation(nextNotifications); - }; - - return ( - - - Уведомления - Настройки почтовых и push-уведомлений. - - - - Email - handleToggle('email', 'mentions', checked)} - aria-label="Email уведомления об упоминаниях" - /> - handleToggle('email', 'daily_summary', checked)} - aria-label="Email ежедневная сводка" - /> - handleToggle('email', 'task_assigned', checked)} - aria-label="Email уведомления о назначении задач" - /> - - - - Push - handleToggle('push', 'reminders', checked)} - aria-label="Push напоминания" - /> - handleToggle('push', 'task_assigned', checked)} - aria-label="Push уведомления о назначении задач" - /> - - - - ); -} - -export { ProfileNotificationsCard }; diff --git a/src/pages/profile/ui/ProfilePage.skeleton.tsx b/src/pages/profile/ui/ProfilePage.skeleton.tsx deleted file mode 100644 index 69777ad..0000000 --- a/src/pages/profile/ui/ProfilePage.skeleton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Card, CardContent, CardHeader, Separator, Skeleton } from 'shared/ui'; - -function ProfilePageSkeleton() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} - -export { ProfilePageSkeleton }; diff --git a/src/pages/profile/ui/ProfilePage.tsx b/src/pages/profile/ui/ProfilePage.tsx deleted file mode 100644 index e08ef84..0000000 --- a/src/pages/profile/ui/ProfilePage.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client'; - -import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from 'shared/ui'; -import { cn } from 'shared/lib/utils'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { ProfilePageSkeleton } from './ProfilePage.skeleton'; -import { ProfileIdentityCard } from './ProfileIdentityCard'; -import { ProfileSecurityCard } from './ProfileSecurityCard'; -import { ProfileNotificationsCard } from './ProfileNotificationsCard'; -import { SignOut } from './SignOut'; -import { userFabricKeys, UserQueries } from 'entities/user'; - -interface ProfilePageProps { - className?: string; -} - -function ProfilePage({ className }: ProfilePageProps) { - const queryClient = useQueryClient(); - const invalidateUser = async () => - await queryClient.invalidateQueries({ queryKey: userFabricKeys.me() }); - - const query = useQuery(UserQueries.getMe()); - if (query.isLoading) { - return ; - } - - if (query.isError || !query.data) { - return ( - - - Не удалось загрузить профиль - Проверьте подключение и попробуйте снова. - - - Повторить - - - ); - } - return ( - - - - - - - - - - ); -} - -export { ProfilePage }; diff --git a/src/pages/profile/ui/ProfileSecurityCard.tsx b/src/pages/profile/ui/ProfileSecurityCard.tsx deleted file mode 100644 index bdde0b0..0000000 --- a/src/pages/profile/ui/ProfileSecurityCard.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Separator, - Switch, -} from 'shared/ui'; -import { ComponentProps } from 'react'; -import { formatDate } from 'shared/lib/utils'; -import { useQuery } from '@tanstack/react-query'; -import { UserQueries } from 'entities/user'; - -function ProfileSecurityCard(props: Omit, 'children'>) { - const query = useQuery(UserQueries.getMe()); - const is2faEnabled = query.data?.security.is2faEnabled ?? false; - const lastPasswordChange = formatDate(query.data?.security.lastPasswordChange ?? ''); - - return ( - - - Безопасность - Состояние двухфакторной защиты и пароль. - - - - Двухфакторная аутентификация - - - - - Последняя смена пароля - {lastPasswordChange} - - - - ); -} - -export { ProfileSecurityCard }; diff --git a/src/pages/profile/ui/SignOut.tsx b/src/pages/profile/ui/SignOut.tsx deleted file mode 100644 index ec9fe51..0000000 --- a/src/pages/profile/ui/SignOut.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { LogOut } from 'lucide-react'; -import { ComponentProps } from 'react'; -import { Button } from 'shared/ui'; -import { AccessToken } from 'shared/api'; -import { routes } from 'shared/config'; -import { useRouter } from 'next/navigation'; -import { toast } from 'sonner'; -import { useQueryClient } from '@tanstack/react-query'; -import { useSignOut } from '../model/useSignOut'; - -function SignOut(props: Omit, 'children'>) { - const router = useRouter(); - const queryClient = useQueryClient(); - - const signoutMutation = useSignOut({ - onSuccess: (response) => { - AccessToken.clear(); - queryClient.clear(); - router.replace(routes.auth.signin()); - toast.success(response.message || 'Вы вышли из аккаунта'); - }, - }); - - return ( - signoutMutation.mutate()} - disabled={signoutMutation.isPending} - {...props} - > - Выйти - - - ); -} - -export { SignOut }; diff --git a/src/pages/profile/ui/TabsNav.tsx b/src/pages/profile/ui/TabsNav.tsx new file mode 100644 index 0000000..54628c7 --- /dev/null +++ b/src/pages/profile/ui/TabsNav.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { ComponentProps } from 'react'; +import type { Route } from 'next'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { routes } from 'shared/config'; +import { classNames } from 'shared/lib/utils'; + +const tabs: { key: Route; label: string }[] = [ + { key: routes.profile.me(), label: 'Пользователь' }, + { key: routes.profile.security(), label: 'Безопасность' }, + { key: routes.profile.notifications(), label: 'Уведомления' }, +]; + +export function TabsNav({ className, ...props }: ComponentProps<'div'>) { + const pathname = usePathname(); + + return ( + + {tabs.map((tab) => { + const active = pathname === tab.key; + + return ( + + {tab.label} + {active && } + + ); + })} + + ); +} diff --git a/src/pages/profile/ui/me/IdentityItem.tsx b/src/pages/profile/ui/me/IdentityItem.tsx new file mode 100644 index 0000000..ed9f210 --- /dev/null +++ b/src/pages/profile/ui/me/IdentityItem.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { Item, ItemActions, ItemContent, ItemMedia } from 'shared/ui'; +import { UploadAvatar } from 'features/upload-avatar'; +import { SignOut } from 'features/auth/sign-out'; +import { TUser } from 'entities/user'; + +type AccountIdentityItemProps = { + profile: TUser.UserResponse['profile']; + email: string; +}; + +function IdentityItem({ profile, email }: AccountIdentityItemProps) { + const fullName = `${profile.firstName} ${profile.lastName}`; + + return ( + + + + + + + {fullName} + {email} + + {profile.bio?.trim() || 'Добавьте информацию о себе в профиле'} + + + + + + + + ); +} + +export { IdentityItem }; diff --git a/src/pages/profile/ui/me/MePage.tsx b/src/pages/profile/ui/me/MePage.tsx new file mode 100644 index 0000000..69c4d98 --- /dev/null +++ b/src/pages/profile/ui/me/MePage.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { ComponentProps, useEffect } from 'react'; +import { + Card, + CardDescription, + CardHeader, + CardSection, + CardTitle, + FloatingSaveBar, + Separator, +} from 'shared/ui'; +import { useForm, useWatch } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { TUser, UserQueries } from 'entities/user'; +import { ProfileForm as ProfileFormSchema } from '../../model/schemas'; +import type { ProfileFormValues } from '../../model/types'; +import { useUpdateProfile } from '../../model/useUpdateProfile'; +import { useQuery } from '@tanstack/react-query'; +import { IdentityItem } from './IdentityItem'; +import { ProfileForm } from './ProfileForm'; +import { UploadHttp } from 'entities/file'; + +function MePage(props: Omit, 'children'>) { + const query = useQuery(UserQueries.getMe()); + const profile = query.data?.profile; + const email = query.data?.email; + + const form = useForm({ + resolver: zodResolver(ProfileFormSchema), + defaultValues: { + firstName: '', + lastName: '', + bio: '', + }, + }); + const formValues = useWatch({ control: form.control }); + const updateProfileMutation = useUpdateProfile(); + + useEffect(() => { + if (!profile) { + return; + } + + form.reset({ + firstName: profile.firstName, + lastName: profile.lastName, + bio: profile.bio || '', + }); + }, [form, profile]); + + if (!profile || !email) { + return ( + + + Профиль + Данные профиля пока недоступны. + + + ); + } + + const profileFormKeys: Array = ['firstName', 'lastName', 'bio']; + const dirty = profileFormKeys.some( + (key) => (formValues[key] ?? '').trim() !== (profile[key] ?? '').trim() + ); + + const onSubmit = (data: ProfileFormValues) => { + const body: TUser.ProfileUpdateBody = { + firstName: data.firstName.trim(), + lastName: data.lastName.trim(), + bio: data.bio ? data.bio.trim() : '', + }; + + updateProfileMutation.mutate(body); + }; + + void UploadHttp.uploadFile; //todo temporary. fsd linter + + return ( + <> + + + + + + + form.reset({ + firstName: profile.firstName, + lastName: profile.lastName, + bio: profile.bio || '', + }) + } + pending={updateProfileMutation.isPending} + /> + > + ); +} + +export { MePage }; diff --git a/src/pages/profile/ui/me/ProfileForm.tsx b/src/pages/profile/ui/me/ProfileForm.tsx new file mode 100644 index 0000000..0403405 --- /dev/null +++ b/src/pages/profile/ui/me/ProfileForm.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { Controller, UseFormReturn } from 'react-hook-form'; +import { Field, FieldError, FieldGroup, FieldLabel, Input, Textarea } from 'shared/ui'; +import type { ProfileFormValues } from '../../model/types'; + +type AccountProfileFormProps = { + form: UseFormReturn; + onSubmit: (data: ProfileFormValues) => void; +}; + +function ProfileForm({ form, onSubmit }: AccountProfileFormProps) { + return ( + + + ( + + Имя + + {fieldState.invalid && } + + )} + /> + ( + + Фамилия + + {fieldState.invalid && } + + )} + /> + + ( + + О себе + + {fieldState.invalid && } + + )} + /> + + + + ); +} + +export { ProfileForm }; diff --git a/src/pages/profile/ui/notifications/NotificationsPage.tsx b/src/pages/profile/ui/notifications/NotificationsPage.tsx new file mode 100644 index 0000000..0706bc2 --- /dev/null +++ b/src/pages/profile/ui/notifications/NotificationsPage.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useReducer } from 'react'; +import { CardSection, FloatingSaveBar, OptionGroup, Switch } from 'shared/ui'; +import { UserQueries } from 'entities/user'; +import { useUpdateNotifications } from '../../model/useUpdateNotifications'; +import { useQuery } from '@tanstack/react-query'; +import { TeamHttp } from 'entities/team'; +import { notificationItems } from '../../model/config'; +import { NotificationChannel, Notifications } from '../../model/types'; + +type NotificationsState = Notifications | null; + +type NotificationsAction = { + type: 'set'; + payload: NotificationsState; +}; + +const notificationsReducer = ( + _state: NotificationsState, + action: NotificationsAction +): NotificationsState => { + switch (action.type) { + case 'set': + return action.payload; + default: + return _state; + } +}; + +function NotificationsPage() { + const query = useQuery(UserQueries.getMe()); + const notifications = query.data?.notifications; + const [localNotifications, dispatchLocalNotifications] = useReducer( + notificationsReducer, + notifications ?? null + ); + const dirty = JSON.stringify(notifications) !== JSON.stringify(localNotifications); + const sendSettings = useUpdateNotifications(); + + const handleToggle = ( + channel: TChannel, + key: keyof Notifications[TChannel], + checked: boolean + ) => { + if (!localNotifications) { + return; + } + + const nextNotifications: Notifications = { + ...localNotifications, + [channel]: { + ...localNotifications[channel], + [key]: checked, + }, + } as Notifications; + + dispatchLocalNotifications({ type: 'set', payload: nextNotifications }); + }; + + void TeamHttp.getTeam; //todo временно для линтера fsd + + return ( + <> + + {Object.entries(notificationItems).map(([channel, items]) => { + const ch = channel as NotificationChannel; + return ( + { + const key = item.key as keyof Notifications[typeof ch]; + + return { + key: item.key, + label: item.label, + input: (props) => ( + handleToggle(ch, key, checked)} + aria-label={item.ariaLabel} + {...props} + /> + ), + }; + })} + /> + ); + })} + + localNotifications && sendSettings.mutate(localNotifications)} + onDiscard={() => + notifications && dispatchLocalNotifications({ type: 'set', payload: notifications }) + } + pending={sendSettings.isPending} + /> + > + ); +} + +export { NotificationsPage }; diff --git a/src/pages/profile/ui/security/SecurityPage.tsx b/src/pages/profile/ui/security/SecurityPage.tsx new file mode 100644 index 0000000..bb2f9f5 --- /dev/null +++ b/src/pages/profile/ui/security/SecurityPage.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { CardSection, OptionItem, Separator, Switch } from 'shared/ui'; +import { formatDate } from 'shared/lib/utils'; +import { useQuery } from '@tanstack/react-query'; +import { UserQueries } from 'entities/user'; + +function SecurityPage() { + const query = useQuery(UserQueries.getMe()); + const is2faEnabled = query.data?.security.is2faEnabled ?? false; + const lastPasswordChange = formatDate(query.data?.security.lastPasswordChange ?? ''); + + return ( + + ( + + )} + /> + + + Последняя смена пароля + {lastPasswordChange} + + + ); +} + +export { SecurityPage }; diff --git a/src/pages/projects/index.ts b/src/pages/projects/index.ts deleted file mode 100644 index 463973c..0000000 --- a/src/pages/projects/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProjectsPage } from './ui/ProjectsPage'; diff --git a/src/pages/projects/ui/ProjectsPage.tsx b/src/pages/projects/ui/ProjectsPage.tsx deleted file mode 100644 index 462a3d1..0000000 --- a/src/pages/projects/ui/ProjectsPage.tsx +++ /dev/null @@ -1,15 +0,0 @@ -'use client'; - -import { UserQueries } from 'entities/user'; -import { useQuery } from '@tanstack/react-query'; - -interface ProjectsPageProps { - className?: string; -} - -function ProjectsPage({ className }: ProjectsPageProps) { - useQuery(UserQueries.getMe()); //todo временно для линтера fsd - return Проекты; -} - -export { ProjectsPage }; diff --git a/src/pages/signin/ui/SigninPage.tsx b/src/pages/signin/ui/SigninPage.tsx index 6be7340..81b8566 100644 --- a/src/pages/signin/ui/SigninPage.tsx +++ b/src/pages/signin/ui/SigninPage.tsx @@ -21,7 +21,7 @@ function SigninPage() { onSuccess={(_, res) => { if (res.success) { AccessToken.token = res.token; - router.replace(routes.profile()); + router.replace(routes.profile.root()); if (res.message) { toast.success(res.message); } diff --git a/src/pages/signup/ui/SignupPage.tsx b/src/pages/signup/ui/SignupPage.tsx index 17d51d9..6bb9043 100644 --- a/src/pages/signup/ui/SignupPage.tsx +++ b/src/pages/signup/ui/SignupPage.tsx @@ -59,7 +59,7 @@ function SignupPage() { if (res.success) { clearDraft(); AccessToken.token = res.token; - router.replace(routes.profile()); + router.replace(routes.profile.root()); if (res.message) { toast.success(res.message); } diff --git a/src/pages/tasks/index.ts b/src/pages/tasks/index.ts deleted file mode 100644 index 0055e23..0000000 --- a/src/pages/tasks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TasksPage } from './ui/TasksPage'; diff --git a/src/pages/tasks/ui/TasksPage.tsx b/src/pages/tasks/ui/TasksPage.tsx deleted file mode 100644 index 745c48c..0000000 --- a/src/pages/tasks/ui/TasksPage.tsx +++ /dev/null @@ -1,11 +0,0 @@ -'use client'; - -interface TasksPageProps { - className?: string; -} - -function TasksPage({ className }: TasksPageProps) { - return Мои задачи; -} - -export { TasksPage }; diff --git a/src/pages/team/index.ts b/src/pages/team/index.ts new file mode 100644 index 0000000..2d48cd9 --- /dev/null +++ b/src/pages/team/index.ts @@ -0,0 +1,5 @@ +export { TabsNav } from './ui/TabsNav'; +export { MembersPage } from './ui/members/MembersPage'; +export { InvitesPage } from './ui/invites/InvitesPage'; +export { RolesPage } from './ui/roles/RolesPage'; +export { Settings } from './ui/settings/SettingsPage'; diff --git a/src/pages/team/model/config.ts b/src/pages/team/model/config.ts new file mode 100644 index 0000000..55f2989 --- /dev/null +++ b/src/pages/team/model/config.ts @@ -0,0 +1,41 @@ +import { TTeam } from 'entities/team'; +import { ComponentProps } from 'react'; +import { Badge } from 'shared/ui'; + +interface IMemberCardConfig { + ringColor: Record; + bgColor: Record; + workloadColor: (w: number) => string; + statusBadgeVariant: (s: TTeam.MemberStatus) => ComponentProps['variant']; + workloadLabel: (w: number) => { text: string; color: string }; +} + +export const memberCardConfig: IMemberCardConfig = { + ringColor: { + banned: 'ring-destructive', + active: 'ring-primary', + inactive: 'ring-muted', + }, + bgColor: { + banned: 'bg-destructive/10', + active: 'bg-card', + inactive: 'bg-muted/90', + }, + workloadColor: (w) => { + if (w === 0) return 'bg-muted/90'; + if (w <= 60) return 'bg-primary/40'; + if (w <= 80) return 'bg-primary'; + return 'bg-orange-500'; + }, + statusBadgeVariant: (s) => { + if (s === 'banned') return 'destructive'; + if (s === 'active') return 'default'; + if (s === 'inactive') return 'outline'; + }, + workloadLabel: (w) => { + if (w === 0) return { text: 'Не загружен', color: 'text-muted-foreground' }; + if (w <= 60) return { text: 'Низкая', color: 'text-muted-foreground' }; + if (w <= 80) return { text: 'Оптимальная', color: 'text-primary' }; + return { text: 'Перегружен', color: 'text-orange-600' }; + }, +}; diff --git a/src/pages/team/model/mock.ts b/src/pages/team/model/mock.ts new file mode 100644 index 0000000..ae84710 --- /dev/null +++ b/src/pages/team/model/mock.ts @@ -0,0 +1,175 @@ +import { TTeam } from 'entities/team'; + +export const members: TTeam.TeamMemberResponse[] = [ + { + id: '1', + fullName: 'Elena Voss', + firstName: 'Elena', + lastName: 'Voss', + role: 'admin', + status: 'active', + avatarUrl: null, + initials: 'EV', + joinedAt: '2024-01-15T09:00:00.000Z', + }, + { + id: '2', + fullName: 'Marcus Chen', + firstName: 'Marcus', + lastName: 'Chen', + role: 'lead', + status: 'active', + avatarUrl: null, + initials: 'MC', + joinedAt: '2024-02-03T10:30:00.000Z', + }, + { + id: '3', + fullName: 'Priya Natarajan', + firstName: 'Priya', + lastName: 'Natarajan', + role: 'member', + status: 'inactive', + avatarUrl: null, + initials: 'PN', + joinedAt: '2024-03-20T08:15:00.000Z', + }, + { + id: '4', + fullName: 'Jonas Bauer', + firstName: 'Jonas', + lastName: 'Bauer', + role: 'moderator', + status: 'active', + avatarUrl: null, + initials: 'JB', + joinedAt: '2024-04-01T11:00:00.000Z', + }, + { + id: '5', + fullName: 'Aiko Tanaka', + firstName: 'Aiko', + lastName: 'Tanaka', + role: 'member', + status: 'inactive', + avatarUrl: null, + initials: 'AT', + joinedAt: '2024-05-10T14:00:00.000Z', + }, + { + id: '6', + fullName: 'Daniel Okafor', + firstName: 'Daniel', + lastName: 'Okafor', + role: 'member', + status: 'banned', + avatarUrl: null, + initials: 'DO', + joinedAt: '2024-06-22T09:45:00.000Z', + }, + { + id: '7', + fullName: 'Sofia Romano', + firstName: 'Sofia', + lastName: 'Romano', + role: 'member', + status: 'active', + avatarUrl: null, + initials: 'SR', + joinedAt: '2024-07-08T13:20:00.000Z', + }, + { + id: '8', + fullName: "Liam O'Brien", + firstName: 'Liam', + lastName: "O'Brien", + role: 'member', + status: 'inactive', + avatarUrl: null, + initials: 'LO', + joinedAt: '2024-08-19T16:00:00.000Z', + }, +]; + +export const invites: TTeam.TeamInvitationResponse[] = [ + { + code: 'inv_1f4a8c2b', + teamId: 'team_01', + teamName: 'Acme Product Team', + teamAvatar: null, + email: 'rahul.shah@acme.io', + role: 'member', + inviterId: 'usr_01', + inviterName: 'Elena Voss', + createdAt: '2026-05-06T12:00:00.000Z', + expiresAt: '2026-05-07T12:00:00.000Z', + }, + { + code: 'inv_8b6d13ea', + teamId: 'team_01', + teamName: 'Acme Product Team', + teamAvatar: null, + email: 'noor.amin@acme.io', + role: 'member', + inviterId: 'usr_02', + inviterName: 'Marcus Chen', + createdAt: '2026-05-06T09:30:00.000Z', + expiresAt: '2026-05-07T09:30:00.000Z', + }, + { + code: 'inv_c9127d4f', + teamId: 'team_01', + teamName: 'Acme Product Team', + teamAvatar: null, + email: 'kai.lindgren@acme.io', + role: 'lead', + inviterId: 'usr_01', + inviterName: 'Elena Voss', + createdAt: '2026-05-05T12:00:00.000Z', + expiresAt: '2026-05-06T18:00:00.000Z', + }, +]; + +export const roles: { + name: TTeam.TeamRole; + description: string; + permissions: string[]; + members: number; +}[] = [ + { + name: 'owner', + description: 'Полный доступ и биллинг.', + permissions: ['Управление участниками', 'Биллинг', 'Все проекты', 'Журнал аудита'], + members: 1, + }, + { + name: 'admin', + description: 'Управление участниками и настройками.', + permissions: ['Управление участниками', 'Все проекты', 'Журнал аудита'], + members: 1, + }, + { + name: 'lead', + description: 'Управление проектами и спринтами.', + permissions: ['Создание проектов', 'Назначение задач', 'Просмотр отчётов'], + members: 2, + }, + { + name: 'moderator', + description: 'Модерация контента и сообщений.', + permissions: ['Просмотр проектов', 'Управление задачами', 'Комментарии'], + members: 1, + }, + { + name: 'member', + description: 'Стандартный доступ участника команды.', + permissions: ['Просмотр проектов', 'Управление задачами', 'Комментарии'], + members: 3, + }, + { + name: 'viewer', + description: 'Только просмотр проектов.', + permissions: ['Просмотр проектов'], + members: 1, + }, +]; diff --git a/src/pages/team/model/roles-mock.ts b/src/pages/team/model/roles-mock.ts new file mode 100644 index 0000000..26b8a26 --- /dev/null +++ b/src/pages/team/model/roles-mock.ts @@ -0,0 +1,81 @@ +export type RoleKey = 'Admin' | 'Member' | 'Guest' | 'Viewer'; + +export type PermissionKey = + | 'task.create' + | 'task.edit' + | 'task.delete' + | 'project.invite' + | 'project.create' + | 'billing.view'; + +export const PERMISSION_GROUPS: { + label: string; + items: { key: PermissionKey; label: string }[]; +}[] = [ + { + label: 'Управление задачами', + items: [ + { key: 'task.create', label: 'Создавать задачи' }, + { key: 'task.edit', label: 'Редактировать задачи' }, + { key: 'task.delete', label: 'Удалять задачи' }, + ], + }, + { + label: 'Доступ к проектам', + items: [ + { key: 'project.invite', label: 'Приглашать участников' }, + { key: 'project.create', label: 'Создавать проекты' }, + ], + }, + { + label: 'Биллинг', + items: [{ key: 'billing.view', label: 'Просмотр счетов' }], + }, +]; + +export const ROLES: { key: RoleKey; description: string; members: number; locked?: boolean }[] = [ + { + key: 'Admin', + description: 'Полный контроль рабочего пространства и биллинг.', + members: 2, + locked: true, + }, + { key: 'Member', description: 'Стандартный доступ участника.', members: 12 }, + { key: 'Guest', description: 'Внешний, ограниченный доступ к проектам.', members: 4 }, + { key: 'Viewer', description: 'Доступ к проектам только для чтения.', members: 7 }, +]; + +export const DEFAULTS: Record> = { + Admin: { + 'task.create': true, + 'task.edit': true, + 'task.delete': true, + 'project.invite': true, + 'project.create': true, + 'billing.view': true, + }, + Member: { + 'task.create': true, + 'task.edit': true, + 'task.delete': false, + 'project.invite': false, + 'project.create': true, + 'billing.view': false, + }, + Guest: { + 'task.create': true, + 'task.edit': false, + 'task.delete': false, + 'project.invite': false, + 'project.create': false, + 'billing.view': false, + }, + Viewer: { + 'task.create': false, + 'task.edit': false, + 'task.delete': false, + 'project.invite': false, + 'project.create': false, + 'billing.view': false, + }, +}; diff --git a/src/pages/team/model/types.ts b/src/pages/team/model/types.ts new file mode 100644 index 0000000..337e234 --- /dev/null +++ b/src/pages/team/model/types.ts @@ -0,0 +1,11 @@ +export type SettingsValues = { + teamName: string; + slug: string; + defaultRole: 'Member' | 'Guest' | 'Viewer'; + autoJoin: boolean; + autoJoinDomain: string; + linkExpiration: '24h' | '7d' | 'never'; + requireApproval: boolean; +}; + +export type SettingsSetter = (k: K, v: SettingsValues[K]) => void; diff --git a/src/pages/team/ui/TabsNav.tsx b/src/pages/team/ui/TabsNav.tsx new file mode 100644 index 0000000..e571b30 --- /dev/null +++ b/src/pages/team/ui/TabsNav.tsx @@ -0,0 +1,52 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { routes } from 'shared/config'; +import type { Route } from 'next'; +import { Badge } from 'shared/ui'; +import { classNames } from 'shared/lib/utils'; +import { ComponentProps } from 'react'; + +//todo principle of a single source of truth +const tabs: { key: Route; label: string; badge?: number }[] = [ + { key: routes.team.members(), label: 'Участники', badge: 8 }, + { key: routes.team.invites(), label: 'Приглашения', badge: 3 }, + { key: routes.team.roles(), label: 'Роли и права' }, + { key: routes.team.settings(), label: 'Настройки' }, +]; + +export function TabsNav({ className, ...props }: ComponentProps<'div'>) { + const pathname = usePathname(); + + return ( + + {tabs.map((t) => { + const active = pathname === t.key; + + return ( + + {t.label} + {t.badge !== undefined && {t.badge}} + {active && } + + ); + })} + + ); +} diff --git a/src/pages/team/ui/invites/InviteCard.skeleton.tsx b/src/pages/team/ui/invites/InviteCard.skeleton.tsx new file mode 100644 index 0000000..367f487 --- /dev/null +++ b/src/pages/team/ui/invites/InviteCard.skeleton.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from 'shared/ui'; + +export function InviteCardSkeleton() { + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/pages/team/ui/invites/InviteCard.tsx b/src/pages/team/ui/invites/InviteCard.tsx new file mode 100644 index 0000000..8f8f773 --- /dev/null +++ b/src/pages/team/ui/invites/InviteCard.tsx @@ -0,0 +1,67 @@ +import { + Badge, + Button, + Item, + ItemActions, + ItemContent, + ItemFooter, + ItemGroup, + ItemHeader, +} from 'shared/ui'; +import { classNames } from 'shared/lib/utils'; +import { TTeam } from 'entities/team'; +import { ComponentProps } from 'react'; +import { Clock, Copy, RotateCw, X } from 'lucide-react'; + +interface InviteCardProps extends Omit, 'children'> { + inv: TTeam.TeamInvitationResponse; +} + +export function InviteCard({ className, inv, ...props }: InviteCardProps) { + return ( + + + + + {inv.email} + + {inv.role} + Отправлено {inv.createdAt}{' '} + {/*todo calculate*/} + + + + + + + + + + + + + Ссылка истекает через{' '} + {inv.expiresAt} + {/*todo calculate*/} + + + + + + Отправить повторно + + + Копировать ссылку + + + + + ); +} diff --git a/src/pages/team/ui/invites/InviteModal.tsx b/src/pages/team/ui/invites/InviteModal.tsx new file mode 100644 index 0000000..7c31478 --- /dev/null +++ b/src/pages/team/ui/invites/InviteModal.tsx @@ -0,0 +1,95 @@ +import { Mail, Send } from 'lucide-react'; +import { useState } from 'react'; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Field, + FieldContent, + FieldDescription, + FieldLabel, + FieldTitle, + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, + Label, + RadioGroup, + RadioGroupItem, +} from 'shared/ui'; + +const roleOptions = [ + { value: 'Admin', desc: 'Полный доступ' }, + { value: 'Product', desc: 'Управление проектами' }, + { value: 'Engineer', desc: 'Разработка функций' }, + { value: 'Designer', desc: 'Дизайн функций' }, + { value: 'Marketing', desc: 'Ведение кампаний' }, +] as const; + +export function InviteModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const [role, setRole] = useState('Engineer'); + const [email, setEmail] = useState(''); + + return ( + !nextOpen && onClose()}> + + + Пригласить участника + + Участнику будет отправлена безопасная ссылка для входа в рабочее пространство. + + + + + Email адрес + + + + + + + setEmail(e.target.value)} + placeholder="name@company.com" + type="email" + /> + + + + + Назначить роль + + {roleOptions.map(({ value, desc }) => ( + + + + {value} + {desc} + + + + + ))} + + + + + + + Отмена + + + Отправить приглашение + + + + + ); +} diff --git a/src/pages/team/ui/invites/InvitesPage.tsx b/src/pages/team/ui/invites/InvitesPage.tsx new file mode 100644 index 0000000..a983165 --- /dev/null +++ b/src/pages/team/ui/invites/InvitesPage.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { invites } from '../../model/mock'; +import { InviteCard } from './InviteCard'; +import { InviteCardSkeleton } from './InviteCard.skeleton'; + +export function InvitesPage() { + const [loading, setLoading] = useState(true); + + useEffect(() => { + const t = setTimeout(() => setLoading(false), 700); + return () => clearTimeout(t); + }, []); + + return ( + + {loading + ? Array.from({ length: 6 }).map((_, i) => ) + : invites.map((inv) => )} + + ); +} diff --git a/src/pages/team/ui/members/MemberCard.skeleton.tsx b/src/pages/team/ui/members/MemberCard.skeleton.tsx new file mode 100644 index 0000000..342f751 --- /dev/null +++ b/src/pages/team/ui/members/MemberCard.skeleton.tsx @@ -0,0 +1,24 @@ +import { Skeleton } from 'shared/ui'; + +export function MemberCardSkeleton() { + return ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/pages/team/ui/members/MemberCard.tsx b/src/pages/team/ui/members/MemberCard.tsx new file mode 100644 index 0000000..fc04381 --- /dev/null +++ b/src/pages/team/ui/members/MemberCard.tsx @@ -0,0 +1,93 @@ +import { + Avatar, + AvatarFallback, + AvatarImage, + Badge, + Item, + ItemContent, + ItemFooter, + ItemGroup, + ItemHeader, + Progress, +} from 'shared/ui'; +import { classNames } from 'shared/lib/utils'; +import { TTeam } from 'entities/team'; +import { memberCardConfig as cfg } from '../../model/config'; +import { ComponentProps } from 'react'; + +const workload = 61; //todo: mock +const skills = ['Design System', 'Sprint Plan']; //todo: mock +const backOn = '2026-05-10'; //todo: mock +const role = 'Admin'; //todo: mock +const email = 'email@example.com'; //todo: mock + +interface MemberCardProps extends Omit, 'children'> { + member: TTeam.TeamMemberResponse; +} + +export function MemberCard({ className, member, ...props }: MemberCardProps) { + const wl = cfg.workloadLabel(workload); + + return ( + + + + + + + + + {member.fullName} + {email} + + + + {role} + {member.status} + {(skills ?? []).map((s) => ( + + {s} + + ))} + + + + + Загруженность + + + {wl.text} + {workload}% + + + [data-slot=progress-indicator]]:' + cfg.workloadColor(workload), + ])} + value={workload} + /> + {backOn && ( + + В отпуске + Вернётся {backOn} + + )} + + + + ); +} diff --git a/src/pages/team/ui/members/MembersPage.tsx b/src/pages/team/ui/members/MembersPage.tsx new file mode 100644 index 0000000..1e600d8 --- /dev/null +++ b/src/pages/team/ui/members/MembersPage.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { ChangeEvent, useEffect, useMemo, useState } from 'react'; +import { Filter, Plus, SlidersHorizontal } from 'lucide-react'; +import { MemberCardSkeleton } from './MemberCard.skeleton'; +import { MemberCard } from './MemberCard'; +import { InviteModal } from '../invites/InviteModal'; +import { members } from '../../model/mock'; +import { Button, Search } from 'shared/ui'; +import { debounce } from 'shared/lib/utils'; +import { UserHttp } from 'entities/user'; + +export function MembersPage() { + const [search, setSearch] = useState(''); + const [filtered, setFiltered] = useState(members); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(true); + + UserHttp.getMyInvites; //todo временно для fsd + + const onFilter = useMemo( + () => + debounce((value: string) => { + setFiltered( + members.filter( + (m) => + m.fullName.toLowerCase().includes(value.trim().toLowerCase()) || + m.role.toLowerCase().includes(value.trim().toLowerCase()) + ) + ); + }, 300), + [] + ); + + useEffect(() => { + const t = setTimeout(() => setLoading(false), 700); + return () => clearTimeout(t); + }, []); + + const onChange = (e: ChangeEvent) => { + const value = e.target.value; + + setSearch(value); + onFilter.debouncedCallback(value); + }; + + return ( + <> + + + setOpen(true)}> + Пригласить + + + + + + Показано {filtered.length} из {members.length} + + + + Фильтр + + + Сортировка + + + + + + {loading + ? Array.from({ length: 8 }).map((_, i) => ) + : filtered.map((m) => )} + + + setOpen(false)} /> + > + ); +} diff --git a/src/pages/team/ui/roles/Permissions.tsx b/src/pages/team/ui/roles/Permissions.tsx new file mode 100644 index 0000000..564c85c --- /dev/null +++ b/src/pages/team/ui/roles/Permissions.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useState } from 'react'; +import { Badge, CardSection, OptionGroup, Switch } from 'shared/ui'; +import { DEFAULTS, PERMISSION_GROUPS, PermissionKey, RoleKey, ROLES } from '../../model/roles-mock'; + +interface PermissionsProps { + className?: string; + active: RoleKey; +} + +export function Permissions({ className, active }: PermissionsProps) { + const [matrix, setMatrix] = useState(DEFAULTS); + + const update = (role: RoleKey, key: PermissionKey, v: boolean) => { + setMatrix((m) => ({ ...m, [role]: { ...m[role], [key]: v } })); + }; + + return ( + + Права роли {active} + {ROLES.find((r) => r.key === active)?.locked && ( + Системная роль + )} + > + } + description={`Настройте действия, разрешённые для роли ${active}.`} + > + {PERMISSION_GROUPS.map((group) => ( + { + const lockedPerm = p.key === 'billing.view' && active !== 'Admin'; + return { + key: p.key, + label: p.label, + hint: lockedPerm ? 'Зарезервировано для роли Admin' : undefined, + input: (props) => ( + update(active, p.key, v)} + disabled={lockedPerm} + {...props} + /> + ), + }; + })} + /> + ))} + + ); +} diff --git a/src/pages/team/ui/roles/RolesList.tsx b/src/pages/team/ui/roles/RolesList.tsx new file mode 100644 index 0000000..cc95c58 --- /dev/null +++ b/src/pages/team/ui/roles/RolesList.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { Plus, Shield } from 'lucide-react'; +import { Dispatch, SetStateAction } from 'react'; +import { + Button, + Field, + FieldContent, + FieldDescription, + FieldLabel, + FieldTitle, + RadioGroup, + RadioGroupItem, +} from 'shared/ui'; +import { RoleKey, ROLES } from '../../model/roles-mock'; + +interface RolesListProps { + className?: string; + active: RoleKey; + setActive: Dispatch>; +} + +export function RolesList({ className, active, setActive }: RolesListProps) { + return ( + + setActive(v)}> + {ROLES.map(({ key, description, members }) => ( + + + + + + + + + {key} + {members} + + + {description} + + + + + ))} + + + Новая роль + + + ); +} diff --git a/src/pages/team/ui/roles/RolesPage.tsx b/src/pages/team/ui/roles/RolesPage.tsx new file mode 100644 index 0000000..f7856f1 --- /dev/null +++ b/src/pages/team/ui/roles/RolesPage.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useState } from 'react'; +import { FloatingSaveBar } from 'shared/ui'; +import { DEFAULTS, RoleKey } from '../../model/roles-mock'; +import { RolesList } from 'pages/team/ui/roles/RolesList'; +import { Permissions } from 'pages/team/ui/roles/Permissions'; + +export function RolesPage() { + const [matrix, setMatrix] = useState(DEFAULTS); + const [saved, setSaved] = useState(DEFAULTS); + const [active, setActive] = useState('Member'); + + const dirty = JSON.stringify(matrix) !== JSON.stringify(saved); + + return ( + <> + + + + + + setSaved(matrix)} + onDiscard={() => setMatrix(saved)} + /> + > + ); +} diff --git a/src/pages/team/ui/settings/DangerZone.tsx b/src/pages/team/ui/settings/DangerZone.tsx new file mode 100644 index 0000000..fb4b5ff --- /dev/null +++ b/src/pages/team/ui/settings/DangerZone.tsx @@ -0,0 +1,27 @@ +import { AlertTriangle } from 'lucide-react'; +import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from 'shared/ui'; +import { DeleteWorkspaceDialog } from './DeleteWorkspaceDialog'; + +interface Props { + workspaceName: string; +} + +export function DangerZone({ workspaceName }: Props) { + return ( + + + + + + Опасная зона + + Навсегда удалить это рабочее пространство со всеми проектами, задачами и данными. Действие + необратимо. + + + + + + + ); +} diff --git a/src/pages/team/ui/settings/DefaultSettings.tsx b/src/pages/team/ui/settings/DefaultSettings.tsx new file mode 100644 index 0000000..6a7049f --- /dev/null +++ b/src/pages/team/ui/settings/DefaultSettings.tsx @@ -0,0 +1,74 @@ +import { + CardSection, + Field, + FieldLabel, + Input, + OptionItem, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, + Switch, +} from 'shared/ui'; +import { type SettingsSetter, type SettingsValues } from '../../model/types'; +import { useId } from 'react'; + +type Props = { + settings: SettingsValues; + set: SettingsSetter; +}; + +export function DefaultSettings({ settings, set }: Props) { + const roleId = useId(); + const autoJoinId = useId(); + + return ( + + + Роль по умолчанию для новых участников + set('defaultRole', value as SettingsValues['defaultRole'])} + > + + + + + + Member + Guest + Viewer + + + + + + Домен для автоматического входа + set('autoJoinDomain', e.target.value)} + placeholder="company.com" + /> + + ( + set('autoJoin', v)} + {...props} + /> + )} + /> + + ); +} diff --git a/src/pages/team/ui/settings/DeleteWorkspaceDialog.tsx b/src/pages/team/ui/settings/DeleteWorkspaceDialog.tsx new file mode 100644 index 0000000..0a65e60 --- /dev/null +++ b/src/pages/team/ui/settings/DeleteWorkspaceDialog.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + Button, + Input, +} from 'shared/ui'; + +interface Props { + workspaceName: string; +} + +export function DeleteWorkspaceDialog({ workspaceName }: Props) { + const [inputValue, setInputValue] = useState(''); + + const isMatch = inputValue.trim() === workspaceName.trim(); + + return ( + + + + Удалить рабочее пространство + + + + + Удалить рабочее пространство? + + Это действие необратимо. Для подтверждения введите название рабочего пространства: + {workspaceName} + + + setInputValue(e.target.value)} + placeholder={workspaceName} + aria-label="Название рабочего пространства для подтверждения удаления" + /> + + setInputValue('')}>Отмена + + Удалить + + + + + ); +} diff --git a/src/pages/team/ui/settings/InviteSecurity.tsx b/src/pages/team/ui/settings/InviteSecurity.tsx new file mode 100644 index 0000000..ea12482 --- /dev/null +++ b/src/pages/team/ui/settings/InviteSecurity.tsx @@ -0,0 +1,64 @@ +import { + CardSection, + Field, + FieldLabel, + OptionItem, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, + Switch, +} from 'shared/ui'; +import { type SettingsSetter, type SettingsValues } from '../../model/types'; +import { useId } from 'react'; + +type Props = { + settings: SettingsValues; + set: SettingsSetter; +}; + +export function InviteSecurity({ settings, set }: Props) { + const id = useId(); + + return ( + + + Срок действия ссылки приглашения + + set('linkExpiration', value as SettingsValues['linkExpiration']) + } + > + + + + + + Истекает через 24 часа + Истекает через 7 дней + Не истекает + + + + + ( + set('requireApproval', v)} + {...props} + /> + )} + /> + + ); +} diff --git a/src/pages/team/ui/settings/SettingsPage.tsx b/src/pages/team/ui/settings/SettingsPage.tsx new file mode 100644 index 0000000..2f07655 --- /dev/null +++ b/src/pages/team/ui/settings/SettingsPage.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useState } from 'react'; +import { FloatingSaveBar } from 'shared/ui'; +import { type SettingsValues } from '../../model/types'; +import { WorkspaceIdentity } from './WorkspaceIdentity'; +import { DefaultSettings } from './DefaultSettings'; +import { InviteSecurity } from './InviteSecurity'; +import { DangerZone } from './DangerZone'; + +const INITIAL: SettingsValues = { + teamName: 'Acme Inc.', + slug: 'acme', + defaultRole: 'Member', + autoJoin: false, + autoJoinDomain: 'acme.io', + linkExpiration: '7d', + requireApproval: true, +}; + +export function Settings() { + const [settings, setSettings] = useState(INITIAL); + const [saved, setSaved] = useState(INITIAL); + const dirty = JSON.stringify(settings) !== JSON.stringify(saved); + + const set = (k: K, v: SettingsValues[K]) => + setSettings((s) => ({ ...s, [k]: v })); + + return ( + <> + + + + + + + + setSaved(settings)} + onDiscard={() => setSettings(saved)} + /> + > + ); +} diff --git a/src/pages/team/ui/settings/WorkspaceIdentity.tsx b/src/pages/team/ui/settings/WorkspaceIdentity.tsx new file mode 100644 index 0000000..cd150ed --- /dev/null +++ b/src/pages/team/ui/settings/WorkspaceIdentity.tsx @@ -0,0 +1,64 @@ +import { + CardSection, + Field, + FieldLabel, + Input, + InputGroup, + InputGroupAddon, + InputGroupInput, +} from 'shared/ui'; +import { type SettingsSetter, type SettingsValues } from '../../model/types'; +import { UploadAvatar } from 'features/upload-avatar'; +import { UsersIcon } from 'lucide-react'; +import { useId } from 'react'; + +interface Props { + settings: SettingsValues; + set: SettingsSetter; +} + +export function WorkspaceIdentity({ settings, set }: Props) { + const idName = useId(); + const idSlug = useId(); + + return ( + + + , + }} + /> + + + Название команды + set('teamName', e.target.value)} + /> + + + URL рабочего пространства + + app.acme.io/ + + set('slug', e.target.value.replace(/[^a-z0-9-]/gi, '').toLowerCase()) + } + /> + + + + + + ); +} diff --git a/src/shared/api/instance.ts b/src/shared/api/instance.ts index e191ab7..76191d8 100644 --- a/src/shared/api/instance.ts +++ b/src/shared/api/instance.ts @@ -8,6 +8,6 @@ const AXIOS_INSTANCE = Axios.create({ applyInterceptors(AXIOS_INSTANCE); -export const instance = (config: AxiosRequestConfig): Promise => { +export const instance = async (config: AxiosRequestConfig): Promise => { return AXIOS_INSTANCE(config).then(({ data }) => data); }; diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts index 73b00d7..8a9e90a 100644 --- a/src/shared/config/routes.ts +++ b/src/shared/config/routes.ts @@ -2,9 +2,19 @@ import type { Route } from 'next'; export const routes = { home: (): Route => '/', - profile: (): Route => '/profile', - projects: (): Route => '/projects', - tasks: (): Route => '/tasks', + profile: { + root: (): Route => '/profile', + me: (): Route => '/profile/me', + security: (): Route => '/profile/security', + notifications: (): Route => '/profile/notifications', + }, + team: { + root: (): Route => '/team', + members: (): Route => '/team/members', + invites: (): Route => '/team/invites', + roles: (): Route => '/team/roles', + settings: (): Route => '/team/settings', + }, auth: { signin: (): Route => '/signin', signup: (): Route => '/signup', diff --git a/src/shared/lib/hooks/index.ts b/src/shared/lib/hooks/index.ts index ae32932..697f6fb 100644 --- a/src/shared/lib/hooks/index.ts +++ b/src/shared/lib/hooks/index.ts @@ -1,6 +1,5 @@ export { useControllableState, type UseControllableStateProps } from './useControllableState'; export { useIsMobile } from './useMobile'; -export { useDebouncedCallback } from './useDebouncedCallback'; export { useQueuedDebouncedMutation } from './useQueuedDebouncedMutation'; export { useLocalStorageDraft, type DraftWithTTL } from './useLocalStorageDraft'; export { useTimer, type UseTimerOptions, type UseTimerReturn } from './use-timer/useTimer'; diff --git a/src/shared/lib/hooks/useDebouncedCallback.ts b/src/shared/lib/hooks/useDebouncedCallback.ts deleted file mode 100644 index ecfdab9..0000000 --- a/src/shared/lib/hooks/useDebouncedCallback.ts +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useRef } from 'react'; - -interface UseDebouncedCallbackReturn { - debouncedCallback: (...args: TArgs) => void; - cancelDebouncedCallback: () => void; -} - -export function useDebouncedCallback( - callback: (...args: TArgs) => void, - delayMs: number -): UseDebouncedCallbackReturn { - const callbackRef = useRef(callback); - const timeoutRef = useRef | null>(null); - - useEffect(() => { - callbackRef.current = callback; - }, [callback]); - - const cancelDebouncedCallback = useCallback(() => { - if (!timeoutRef.current) { - return; - } - - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - }, []); - - const debouncedCallback = useCallback( - (...args: TArgs) => { - cancelDebouncedCallback(); - timeoutRef.current = setTimeout(() => { - callbackRef.current(...args); - }, delayMs); - }, - [cancelDebouncedCallback, delayMs] - ); - - useEffect(() => cancelDebouncedCallback, [cancelDebouncedCallback]); - - return { - debouncedCallback, - cancelDebouncedCallback, - }; -} diff --git a/src/shared/lib/hooks/useQueuedDebouncedMutation.ts b/src/shared/lib/hooks/useQueuedDebouncedMutation.ts index 20c683e..4863ca5 100644 --- a/src/shared/lib/hooks/useQueuedDebouncedMutation.ts +++ b/src/shared/lib/hooks/useQueuedDebouncedMutation.ts @@ -1,7 +1,7 @@ 'use client'; -import { useCallback, useRef } from 'react'; -import { useDebouncedCallback } from './useDebouncedCallback'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { debounce } from '../utils'; interface UseQueuedDebouncedMutationOptions { delayMs: number; @@ -62,16 +62,24 @@ export function useQueuedDebouncedMutation({ } }, [isEqual, mutationFn, onError, onSuccess]); - const { debouncedCallback } = useDebouncedCallback(() => { - void flushQueue(); - }, delayMs); + const flushQueueRef = useRef(flushQueue); + useEffect(() => { + flushQueueRef.current = flushQueue; + }, [flushQueue]); + + const { debouncedCallback: debouncedFlush, cancelDebouncedCallback } = useMemo( + () => debounce(() => void flushQueueRef.current(), delayMs), + [delayMs] + ); + + useEffect(() => cancelDebouncedCallback, [cancelDebouncedCallback]); const enqueueMutation = useCallback( (value: TValue) => { queuedValueRef.current = value; - debouncedCallback(); + debouncedFlush(); }, - [debouncedCallback] + [debouncedFlush] ); const syncPersistedValue = useCallback((value: TValue | null) => { diff --git a/src/shared/lib/utils/debounce/debounce.ts b/src/shared/lib/utils/debounce/debounce.ts new file mode 100644 index 0000000..972be49 --- /dev/null +++ b/src/shared/lib/utils/debounce/debounce.ts @@ -0,0 +1,25 @@ +interface DebouncedCallbackReturn { + debouncedCallback: (...args: TArgs) => void; + cancelDebouncedCallback: () => void; +} + +export function debounce( + callback: (...args: TArgs) => void, + delayMs: number +): DebouncedCallbackReturn { + let timeoutId: ReturnType | null = null; + + const cancelDebouncedCallback = () => { + if (timeoutId === null) return; + clearTimeout(timeoutId); + timeoutId = null; + }; + + return { + cancelDebouncedCallback, + debouncedCallback: (...args: TArgs) => { + cancelDebouncedCallback(); + timeoutId = setTimeout(() => callback(...args), delayMs); + }, + }; +} diff --git a/src/shared/lib/utils/index.ts b/src/shared/lib/utils/index.ts index 7c1612a..eaffba0 100644 --- a/src/shared/lib/utils/index.ts +++ b/src/shared/lib/utils/index.ts @@ -6,3 +6,4 @@ export { setFormErrors } from './set-form-errors'; export { createEntityKeys } from './create-entity-keys'; export { classNames } from './class-names/class-names'; export { throttle } from './throttle/throttle'; +export { debounce } from './debounce/debounce'; diff --git a/src/shared/lib/utils/throttle/throttle.ts b/src/shared/lib/utils/throttle/throttle.ts index b0d6847..46bf7c3 100644 --- a/src/shared/lib/utils/throttle/throttle.ts +++ b/src/shared/lib/utils/throttle/throttle.ts @@ -3,12 +3,18 @@ interface SavedCall { thisArg: TThis; } +interface ThrottledFunction { + (this: TThis, ...args: TArgs): void; + cancel: () => void; +} + export function throttle( func: (this: TThis, ...args: TArgs) => void, ms: number -): (this: TThis, ...args: TArgs) => void { +): ThrottledFunction { let isThrottled = false; let savedCall: SavedCall | null = null; + let timeoutId: ReturnType | null = null; function wrapper(this: TThis, ...args: TArgs) { if (isThrottled) { @@ -23,7 +29,8 @@ export function throttle( isThrottled = true; - setTimeout(() => { + timeoutId = setTimeout(() => { + timeoutId = null; isThrottled = false; if (savedCall) { const { args: savedArgs, thisArg } = savedCall; @@ -33,5 +40,14 @@ export function throttle( }, ms); } + wrapper.cancel = () => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + isThrottled = false; + savedCall = null; + }; + return wrapper; } diff --git a/src/shared/ui/Avatar.tsx b/src/shared/ui/Avatar.tsx index 9576600..dc07e8e 100644 --- a/src/shared/ui/Avatar.tsx +++ b/src/shared/ui/Avatar.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { UserIcon } from 'lucide-react'; import { Avatar as AvatarPrimitive } from 'radix-ui'; import { cn } from 'shared/lib/utils'; @@ -15,7 +16,7 @@ function Avatar({ data-slot="avatar" data-size={size} className={cn( - 'group/avatar after:border-border relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten', + 'group/avatar after:border-border relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-12 data-[size=sm]:size-6 dark:after:mix-blend-lighten', className )} {...props} @@ -35,20 +36,42 @@ function AvatarImage({ className, ...props }: React.ComponentProps) { +}: React.ComponentProps & { + firstName?: string; + lastName?: string; +}) { + const content = children ?? getInitials(firstName, lastName); + return ( + > + {content ?? ( + + )} + ); } +function getInitials(firstName?: string, lastName?: string): string | undefined { + const first = firstName?.trim()[0]?.toUpperCase(); + const last = lastName?.trim()[0]?.toUpperCase(); + + if (first && last) return `${first}${last}`; + if (first) return first; + if (last) return last; + return undefined; +} + function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) { return ( svg]:pointer-events-none [&>svg]:size-3!', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', + destructive: + 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20', + outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', + ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +function Badge({ + className, + variant = 'default', + asChild = false, + ...props +}: React.ComponentProps<'span'> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : 'span'; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/src/shared/ui/Dialog.tsx b/src/shared/ui/Dialog.tsx new file mode 100644 index 0000000..19a9b54 --- /dev/null +++ b/src/shared/ui/Dialog.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; +import { Dialog as DialogPrimitive } from 'radix-ui'; + +import { cn } from 'shared/lib/utils'; +import { XIcon } from 'lucide-react'; +import { Button } from 'shared/ui/button/Button'; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + Close + + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<'div'> & { + showCloseButton?: boolean; +}) { + return ( + + {children} + {showCloseButton && ( + + Close + + )} + + ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/src/shared/ui/Field.tsx b/src/shared/ui/Field.tsx index 4d8b9ba..f4af555 100644 --- a/src/shared/ui/Field.tsx +++ b/src/shared/ui/Field.tsx @@ -109,7 +109,7 @@ function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) { ) { + return ( + + ); +} + +function ItemSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +const itemVariants = cva( + 'group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted', + { + variants: { + variant: { + default: 'border-transparent', + outline: 'border-border', + muted: 'border-transparent bg-muted/50', + destructive: + 'border-destructive/20 bg-destructive/10 text-destructive focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40', + }, + size: { + default: 'gap-2.5 px-3 py-2.5', + sm: 'gap-2.5 px-3 py-2.5', + xs: 'gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +function Item({ + className, + variant = 'default', + size = 'default', + asChild = false, + ...props +}: React.ComponentProps<'div'> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : 'div'; + return ( + + ); +} + +const itemMediaVariants = cva( + 'flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none', + { + variants: { + variant: { + default: 'bg-transparent', + icon: "[&_svg:not([class*='size-'])]:size-4", + image: + 'size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +function ItemMedia({ + className, + variant = 'default', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( + + ); +} + +function ItemContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) { + return ( + a:hover]:text-primary line-clamp-2 text-left text-sm leading-normal font-normal group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4', + className + )} + {...props} + /> + ); +} + +function ItemActions({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +export { + Item, + ItemMedia, + ItemContent, + ItemActions, + ItemGroup, + ItemSeparator, + ItemTitle, + ItemDescription, + ItemHeader, + ItemFooter, +}; diff --git a/src/shared/ui/Kbd.tsx b/src/shared/ui/Kbd.tsx new file mode 100644 index 0000000..81c148c --- /dev/null +++ b/src/shared/ui/Kbd.tsx @@ -0,0 +1,26 @@ +import { cn } from 'shared/lib/utils'; + +function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { + return ( + + ); +} + +function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +export { Kbd, KbdGroup }; diff --git a/src/shared/ui/Progress.tsx b/src/shared/ui/Progress.tsx new file mode 100644 index 0000000..f5429f3 --- /dev/null +++ b/src/shared/ui/Progress.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { Progress as ProgressPrimitive } from 'radix-ui'; + +import { cn } from 'shared/lib/utils'; + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { Progress }; diff --git a/src/shared/ui/RadioGroup.tsx b/src/shared/ui/RadioGroup.tsx new file mode 100644 index 0000000..697a3a0 --- /dev/null +++ b/src/shared/ui/RadioGroup.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { RadioGroup as RadioGroupPrimitive } from 'radix-ui'; + +import { cn } from 'shared/lib/utils'; + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { RadioGroup, RadioGroupItem }; diff --git a/src/shared/ui/Select.tsx b/src/shared/ui/Select.tsx new file mode 100644 index 0000000..735f988 --- /dev/null +++ b/src/shared/ui/Select.tsx @@ -0,0 +1,183 @@ +import * as React from 'react'; +import { Select as SelectPrimitive } from 'radix-ui'; + +import { cn } from 'shared/lib/utils'; +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react'; + +function Select({ ...props }: React.ComponentProps) { + return ; +} + +function SelectGroup({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectValue({ ...props }: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = 'default', + children, + ...props +}: React.ComponentProps & { + size?: 'sm' | 'default'; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = 'item-aligned', + align = 'center', + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/src/shared/ui/button/Button.tsx b/src/shared/ui/button/Button.tsx index e68ea80..be7cbf6 100644 --- a/src/shared/ui/button/Button.tsx +++ b/src/shared/ui/button/Button.tsx @@ -12,6 +12,8 @@ const buttonVariants = cva( default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground', + 'outline-primary': + 'border-primary/50 text-primary bg-background hover:bg-muted dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', ghost: diff --git a/src/shared/ui/card-section/CardSection.tsx b/src/shared/ui/card-section/CardSection.tsx new file mode 100644 index 0000000..3f6e778 --- /dev/null +++ b/src/shared/ui/card-section/CardSection.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { ComponentProps, ReactNode } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from 'shared/ui'; + +interface ICardSectionProps extends Omit, 'title'> { + title: string | ReactNode; + description: string | ReactNode; +} + +function CardSection({ title, description, ...props }: ICardSectionProps) { + return ( + + + {title} + {description} + + + + ); +} + +export { CardSection }; diff --git a/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx b/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx new file mode 100644 index 0000000..b8dc90a --- /dev/null +++ b/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx @@ -0,0 +1,41 @@ +import { Button, Spinner } from 'shared/ui'; + +export function FloatingSaveBar({ + visible, + onSave, + onDiscard, + pending = false, +}: { + visible: boolean; + onSave: () => void; + onDiscard: () => void; + pending?: boolean; +}) { + if (visible) { + return ( + + + + {pending ? ( + + ) : ( + + )} + + + {pending ? 'Сохранение...' : 'Есть несохранённые изменения.'} + + + + + Отменить + + + Сохранить + + + + ); + } + return null; +} diff --git a/src/shared/ui/floating-save-bar/floating-save-bar.stories.tsx b/src/shared/ui/floating-save-bar/floating-save-bar.stories.tsx new file mode 100644 index 0000000..485992f --- /dev/null +++ b/src/shared/ui/floating-save-bar/floating-save-bar.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { FloatingSaveBar } from './FloatingSaveBar'; + +const meta = { + title: 'Shared/FloatingSaveBar', + component: FloatingSaveBar, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], + render: (args) => ( + + + + ), +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Visible: Story = { + args: { + visible: true, + onSave: () => undefined, + onDiscard: () => undefined, + }, +}; + +export const Hidden: Story = { + args: { + visible: false, + onSave: () => undefined, + onDiscard: () => undefined, + }, +}; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index e623cd5..9d44cd3 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -24,3 +24,14 @@ export * from './DropdownMenu'; export * from './Collapsible'; export * from './Avatar'; export * from './Switch'; +export * from './Badge'; +export * from './Item'; +export * from './Kbd'; +export * from './Progress'; +export * from './floating-save-bar/FloatingSaveBar'; +export * from './Dialog'; +export * from './RadioGroup'; +export * from './search/Search'; +export * from './option-group/OptionGroup'; +export * from './card-section/CardSection'; +export * from './Select'; diff --git a/src/shared/ui/input-password/InputPassword.tsx b/src/shared/ui/input-password/InputPassword.tsx index ab11f9e..1acfeb8 100644 --- a/src/shared/ui/input-password/InputPassword.tsx +++ b/src/shared/ui/input-password/InputPassword.tsx @@ -1,13 +1,10 @@ import * as React from 'react'; -import { EyeOffIcon, EyeIcon } from 'lucide-react'; +import { HTMLInputTypeAttribute } from 'react'; +import { EyeIcon, EyeOffIcon } from 'lucide-react'; import { Button, InputGroup, InputGroupAddon, InputGroupInput } from 'shared/ui'; import { useControllableState } from 'shared/lib/hooks'; -import { HTMLInputTypeAttribute } from 'react'; -interface InputPasswordProps extends Omit< - React.ComponentProps, - 'children' -> { +interface InputPasswordProps extends React.ComponentProps { showEyeIcon?: boolean; visible?: boolean; defaultVisibleValue?: boolean; diff --git a/src/shared/ui/option-group/OptionGroup.tsx b/src/shared/ui/option-group/OptionGroup.tsx new file mode 100644 index 0000000..42c492c --- /dev/null +++ b/src/shared/ui/option-group/OptionGroup.tsx @@ -0,0 +1,62 @@ +import { ComponentProps, ReactNode, useId } from 'react'; +import { Label } from 'shared/ui'; +import { classNames } from 'shared/lib/utils'; + +export interface OptionGroupItemProps extends ComponentProps<'div'> { + label: string; + input: (props: { id: string; 'aria-describedby': string }) => ReactNode; + hint?: string; +} + +export interface OptionGroupProps { + name: string; + items: (OptionGroupItemProps & { key: string })[]; +} + +export function OptionItem({ label, hint, input, className, ...props }: OptionGroupItemProps) { + const inputId = useId(); + const hintId = useId(); + + return ( + + + + {label} + + {hint && ( + + {hint} + + )} + + + {input({ id: inputId, 'aria-describedby': hintId })} + + ); +} + +export function OptionGroup({ name, items }: OptionGroupProps) { + return ( + + {name} + + {items.map((item) => ( + + ))} + + + ); +} diff --git a/src/shared/ui/search/Search.tsx b/src/shared/ui/search/Search.tsx new file mode 100644 index 0000000..c22485d --- /dev/null +++ b/src/shared/ui/search/Search.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { SearchIcon } from 'lucide-react'; + +import { InputGroup, InputGroupAddon, InputGroupInput } from '../InputGroup'; +import { Kbd } from '../Kbd'; +import { ComponentProps, useEffect, useRef } from 'react'; + +type SearchProps = ComponentProps; + +export function Search(props: SearchProps) { + const input = useRef(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + input.current?.focus(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + return ( + + + { + input.current = ref; + }} + /> + + + + + ⌘K + + + + ); +} diff --git a/src/widgets/app-sidebar/ui/AppSidebar.tsx b/src/widgets/app-sidebar/ui/AppSidebar.tsx index 193eee4..9c6a591 100644 --- a/src/widgets/app-sidebar/ui/AppSidebar.tsx +++ b/src/widgets/app-sidebar/ui/AppSidebar.tsx @@ -2,14 +2,16 @@ import { AudioWaveform, + ChevronRight, Command, - FolderKanban, GalleryVerticalEnd, - ListTodo, UserRound, + UsersRound, } from 'lucide-react'; import { - Link, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, Sidebar, SidebarContent, SidebarFooter, @@ -18,18 +20,17 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, SidebarRail, } from 'shared/ui'; import { TeamSwitcher } from './TeamSwitcher'; import { NavUser } from './NavUser'; import { routes } from 'shared/config'; +import Link from 'next/link'; const data = { - user: { - name: 'whoami', - email: 'mail@example.com', - avatar: 'https://cdn.ttopen.ru/test.jpeg', - }, teams: [ { name: 'Task Tracker Frontend', @@ -49,6 +50,19 @@ const data = { ], }; +const team = [ + { url: routes.team.members(), title: 'Участники' }, + { url: routes.team.invites(), title: 'Приглашения' }, + { url: routes.team.roles(), title: 'Роли' }, + { url: routes.team.settings(), title: 'Настройки' }, +]; + +const profile = [ + { url: routes.profile.me(), title: 'Пользователь' }, + { url: routes.profile.security(), title: 'Безопасность' }, + { url: routes.profile.notifications(), title: 'Уведомления' }, +]; + export function AppSidebar({ ...props }: React.ComponentProps) { return ( @@ -58,35 +72,59 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - - - - - Мой профиль - - - - - - - - Мои задачи - - - - - - - - Мои Проекты - - - + + + + + + Профиль + + + + + + {profile.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + + + + + + + Команда + + + + + + {team.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + - + diff --git a/src/widgets/app-sidebar/ui/NavUser.tsx b/src/widgets/app-sidebar/ui/NavUser.tsx index 347d9c7..9960ce5 100644 --- a/src/widgets/app-sidebar/ui/NavUser.tsx +++ b/src/widgets/app-sidebar/ui/NavUser.tsx @@ -1,7 +1,6 @@ 'use client'; -import { BadgeCheck, Bell, ChevronsUpDown, LogOut } from 'lucide-react'; - +import { BadgeCheck, ChevronsUpDown } from 'lucide-react'; import { Avatar, AvatarFallback, @@ -13,24 +12,24 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, - Link, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, } from 'shared/ui'; import { routes } from 'shared/config'; +import { UserQueries } from 'entities/user'; +import { useQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import { SignOut } from 'features/auth/sign-out'; -export function NavUser({ - user, -}: { - user: { - name: string; - email: string; - avatar: string; - }; -}) { +export function NavUser() { const { isMobile } = useSidebar(); + const query = useQuery(UserQueries.getMe()); + + if (!query.data) return null; + + const { email, profile } = query.data; return ( @@ -41,13 +40,13 @@ export function NavUser({ size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - - - CN + + + - {user.name} - {user.email} + {profile.firstName} + {email} @@ -60,13 +59,13 @@ export function NavUser({ > - - - CN + + + - {user.name} - {user.email} + {profile.firstName} + {email} @@ -74,17 +73,12 @@ export function NavUser({ - Account - - - - Notifications + Account - - - Log out + + diff --git a/src/widgets/page-layout/index.ts b/src/widgets/page-layout/index.ts new file mode 100644 index 0000000..32121a9 --- /dev/null +++ b/src/widgets/page-layout/index.ts @@ -0,0 +1 @@ +export { PageLayout } from './ui/PageLayout'; diff --git a/src/widgets/page-layout/ui/PageLayout.tsx b/src/widgets/page-layout/ui/PageLayout.tsx new file mode 100644 index 0000000..5b9726d --- /dev/null +++ b/src/widgets/page-layout/ui/PageLayout.tsx @@ -0,0 +1,39 @@ +import { ReactNode } from 'react'; + +interface PageLayoutProps { + title: string; + description?: string; + badge?: ReactNode; + headerSlot?: ReactNode; + nav?: ReactNode; + children: ReactNode; +} + +export function PageLayout({ + title, + description, + badge, + headerSlot, + nav, + children, +}: PageLayoutProps) { + return ( + + + + + {title} + {badge} + + {description && {description}} + + + {headerSlot && {headerSlot}} + + {nav && {nav}} + + {children} + + + ); +}
{fullName}
{email}
- {profile.bio?.trim() || 'Добавьте информацию о себе в профиле'} -
- Имя и фамилия -
- О себе -
Email
Push
+ {profile.bio?.trim() || 'Добавьте информацию о себе в профиле'} +
{member.fullName}
+ Показано {filtered.length} из {members.length} +
a:hover]:text-primary line-clamp-2 text-left text-sm leading-normal font-normal group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4', + className + )} + {...props} + /> + ); +} + +function ItemActions({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ {pending ? 'Сохранение...' : 'Есть несохранённые изменения.'} +
+ {hint} +
{name}
{description}