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 ( + + ); +} + +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()} - + +