create-stitchkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/cli.js +230 -0
- package/package.json +50 -0
- package/template/README.md +69 -0
- package/template/_env +11 -0
- package/template/_env.example +12 -0
- package/template/_gitignore +10 -0
- package/template/biome.json +43 -0
- package/template/bun.lock +1372 -0
- package/template/e2e/starter.spec.ts +237 -0
- package/template/ecosystem.config.cjs +28 -0
- package/template/ecosystem.dev.config.cjs +29 -0
- package/template/package.json +55 -0
- package/template/packages/backend/package.json +31 -0
- package/template/packages/backend/src/cli.ts +12 -0
- package/template/packages/backend/src/domain/errors.ts +9 -0
- package/template/packages/backend/src/domain/repository/github-cache.test.ts +72 -0
- package/template/packages/backend/src/domain/repository/github-cache.ts +205 -0
- package/template/packages/backend/src/index.ts +56 -0
- package/template/packages/backend/src/lib/db.ts +7 -0
- package/template/packages/backend/src/surface.ts +13 -0
- package/template/packages/backend/src/tools.ts +21 -0
- package/template/packages/backend/src/transport/errors.ts +28 -0
- package/template/packages/backend/src/transport/repository-service.ts +18 -0
- package/template/packages/backend/tsconfig.json +8 -0
- package/template/packages/config/package.json +22 -0
- package/template/packages/config/src/server.ts +24 -0
- package/template/packages/config/tsconfig.json +5 -0
- package/template/packages/db/migrations/20260808000000_init/migration.sql +16 -0
- package/template/packages/db/migrations/20260808170000_repository_visibility/migration.sql +4 -0
- package/template/packages/db/migrations/migration_lock.toml +1 -0
- package/template/packages/db/package.json +29 -0
- package/template/packages/db/prisma.config.ts +8 -0
- package/template/packages/db/schema.prisma +31 -0
- package/template/packages/db/tsconfig.json +5 -0
- package/template/packages/frontend/messages/en.json +15 -0
- package/template/packages/frontend/messages/ru.json +15 -0
- package/template/packages/frontend/next.config.ts +18 -0
- package/template/packages/frontend/package.json +62 -0
- package/template/packages/frontend/postcss.config.mjs +1 -0
- package/template/packages/frontend/public/ASSETS.md +6 -0
- package/template/packages/frontend/public/favicon/mascot-stitch.png +0 -0
- package/template/packages/frontend/public/icons/biome.svg +1 -0
- package/template/packages/frontend/public/icons/bun.svg +19 -0
- package/template/packages/frontend/public/icons/next.svg +1 -0
- package/template/packages/frontend/public/icons/postgresql.svg +1 -0
- package/template/packages/frontend/public/icons/prisma.svg +6 -0
- package/template/packages/frontend/public/icons/react-query.svg +9 -0
- package/template/packages/frontend/public/icons/react.svg +1 -0
- package/template/packages/frontend/public/icons/tailwind.svg +12 -0
- package/template/packages/frontend/public/icons/tsconfig.svg +1 -0
- package/template/packages/frontend/public/icons/typescript.svg +7 -0
- package/template/packages/frontend/public/icons/zod.svg +1 -0
- package/template/packages/frontend/public/mascot-stitch.png +0 -0
- package/template/packages/frontend/public/theme-dark.svg +8 -0
- package/template/packages/frontend/public/theme-light.svg +8 -0
- package/template/packages/frontend/src/app/[locale]/error.tsx +19 -0
- package/template/packages/frontend/src/app/[locale]/layout.tsx +59 -0
- package/template/packages/frontend/src/app/[locale]/loading.tsx +14 -0
- package/template/packages/frontend/src/app/[locale]/page.tsx +44 -0
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +151 -0
- package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +40 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/account-showcase.tsx +112 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/admin-showcase.tsx +101 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/catalogue-navigation.tsx +56 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/catalogue-shell.tsx +51 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/code-foundation.tsx +35 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/components-story.tsx +54 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/data-story.tsx +126 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/image-preloader.tsx +8 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/index.ts +3 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-sections.tsx +269 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +159 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/motion-story.tsx +78 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/overlays-story.tsx +134 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/payment-showcase.tsx +82 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/presentation-story.tsx +68 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/primitives-story.tsx +267 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/story-page.tsx +14 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/theme-story-client.tsx +208 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/theme-story.tsx +13 -0
- package/template/packages/frontend/src/app/[locale]/ui/layout.tsx +6 -0
- package/template/packages/frontend/src/app/[locale]/ui/page.tsx +10 -0
- package/template/packages/frontend/src/app/api/og/[locale]/[page]/route.tsx +53 -0
- package/template/packages/frontend/src/app/globals.css +210 -0
- package/template/packages/frontend/src/app/robots.ts +9 -0
- package/template/packages/frontend/src/app/sitemap.ts +22 -0
- package/template/packages/frontend/src/components/brand-mark.tsx +22 -0
- package/template/packages/frontend/src/components/repository-summary.tsx +107 -0
- package/template/packages/frontend/src/components/system-controls.tsx +92 -0
- package/template/packages/frontend/src/components/ui/adaptive-modal.tsx +382 -0
- package/template/packages/frontend/src/components/ui/animated-counter.tsx +80 -0
- package/template/packages/frontend/src/components/ui/avatar.tsx +46 -0
- package/template/packages/frontend/src/components/ui/badge.tsx +94 -0
- package/template/packages/frontend/src/components/ui/button-variants.ts +24 -0
- package/template/packages/frontend/src/components/ui/button.tsx +81 -0
- package/template/packages/frontend/src/components/ui/card-deck.tsx +73 -0
- package/template/packages/frontend/src/components/ui/card.tsx +81 -0
- package/template/packages/frontend/src/components/ui/charts/area-chart.tsx +167 -0
- package/template/packages/frontend/src/components/ui/charts/index.ts +7 -0
- package/template/packages/frontend/src/components/ui/checkbox.tsx +133 -0
- package/template/packages/frontend/src/components/ui/confirmation-modal.tsx +129 -0
- package/template/packages/frontend/src/components/ui/copyable-text.tsx +85 -0
- package/template/packages/frontend/src/components/ui/drawer.tsx +136 -0
- package/template/packages/frontend/src/components/ui/dropdown-menu.tsx +185 -0
- package/template/packages/frontend/src/components/ui/field.tsx +28 -0
- package/template/packages/frontend/src/components/ui/hand-drawn-icons.tsx +41 -0
- package/template/packages/frontend/src/components/ui/index.ts +36 -0
- package/template/packages/frontend/src/components/ui/input.tsx +24 -0
- package/template/packages/frontend/src/components/ui/label.tsx +18 -0
- package/template/packages/frontend/src/components/ui/otp-input.tsx +140 -0
- package/template/packages/frontend/src/components/ui/select.tsx +240 -0
- package/template/packages/frontend/src/components/ui/sidebar.tsx +343 -0
- package/template/packages/frontend/src/components/ui/skeleton.tsx +8 -0
- package/template/packages/frontend/src/components/ui/slider.tsx +31 -0
- package/template/packages/frontend/src/components/ui/slot-drum.tsx +50 -0
- package/template/packages/frontend/src/components/ui/spinner.tsx +30 -0
- package/template/packages/frontend/src/components/ui/switch.tsx +31 -0
- package/template/packages/frontend/src/components/ui/table.tsx +106 -0
- package/template/packages/frontend/src/components/ui/tabs.tsx +196 -0
- package/template/packages/frontend/src/components/ui/textarea.tsx +21 -0
- package/template/packages/frontend/src/components/ui/toaster.tsx +72 -0
- package/template/packages/frontend/src/components/ui/tooltip.tsx +31 -0
- package/template/packages/frontend/src/env.ts +21 -0
- package/template/packages/frontend/src/features/data-table/components/column-helpers.tsx +136 -0
- package/template/packages/frontend/src/features/data-table/components/data-table.tsx +303 -0
- package/template/packages/frontend/src/features/data-table/components/inline-stat.tsx +113 -0
- package/template/packages/frontend/src/features/data-table/components/sortable-header.tsx +33 -0
- package/template/packages/frontend/src/features/data-table/components/table-error.tsx +24 -0
- package/template/packages/frontend/src/features/data-table/components/table-filters.tsx +82 -0
- package/template/packages/frontend/src/features/data-table/hooks/useTableState.ts +111 -0
- package/template/packages/frontend/src/features/data-table/index.ts +26 -0
- package/template/packages/frontend/src/features/data-table/table-features.ts +26 -0
- package/template/packages/frontend/src/hooks/index.ts +3 -0
- package/template/packages/frontend/src/hooks/useInfiniteScroll.ts +67 -0
- package/template/packages/frontend/src/hooks/useIsTouchDevice.ts +25 -0
- package/template/packages/frontend/src/hooks/useMediaQuery.ts +30 -0
- package/template/packages/frontend/src/i18n/locales.ts +6 -0
- package/template/packages/frontend/src/i18n/navigation.ts +4 -0
- package/template/packages/frontend/src/i18n/request.ts +9 -0
- package/template/packages/frontend/src/i18n/routing.ts +8 -0
- package/template/packages/frontend/src/lib/api/client.ts +20 -0
- package/template/packages/frontend/src/lib/api/queries.ts +11 -0
- package/template/packages/frontend/src/lib/query-client.ts +20 -0
- package/template/packages/frontend/src/lib/realtime/repository.ts +21 -0
- package/template/packages/frontend/src/lib/seo/metadata.ts +50 -0
- package/template/packages/frontend/src/lib/seo/pages.test.ts +25 -0
- package/template/packages/frontend/src/lib/seo/pages.ts +107 -0
- package/template/packages/frontend/src/lib/utils/cn.ts +6 -0
- package/template/packages/frontend/src/lib/utils/format.ts +25 -0
- package/template/packages/frontend/src/lib/utils/index.ts +2 -0
- package/template/packages/frontend/src/providers/index.tsx +18 -0
- package/template/packages/frontend/src/providers/react-query.tsx +9 -0
- package/template/packages/frontend/src/providers/realtime.tsx +16 -0
- package/template/packages/frontend/src/proxy.ts +6 -0
- package/template/packages/frontend/src/theme/config.ts +20 -0
- package/template/packages/frontend/src/theme/transition.ts +54 -0
- package/template/packages/frontend/tsconfig.json +13 -0
- package/template/packages/shared/package.json +23 -0
- package/template/packages/shared/src/contracts/repository.ts +28 -0
- package/template/packages/shared/src/events/repository.ts +9 -0
- package/template/packages/shared/src/index.ts +3 -0
- package/template/packages/shared/src/schemas/repository.test.ts +36 -0
- package/template/packages/shared/src/schemas/repository.ts +28 -0
- package/template/packages/shared/tsconfig.json +5 -0
- package/template/playwright.config.ts +15 -0
- package/template/scripts/check-authored.ts +81 -0
- package/template/scripts/dev.ts +23 -0
- package/template/scripts/local-env.ts +12 -0
- package/template/scripts/runtime-smoke.ts +67 -0
- package/template/scripts/tooling-env.ts +15 -0
- package/template/tsconfig.json +13 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
columnFilteringFeature,
|
|
3
|
+
columnVisibilityFeature,
|
|
4
|
+
createFilteredRowModel,
|
|
5
|
+
createPaginatedRowModel,
|
|
6
|
+
createSortedRowModel,
|
|
7
|
+
filterFn_includesString,
|
|
8
|
+
rowPaginationFeature,
|
|
9
|
+
rowSelectionFeature,
|
|
10
|
+
rowSortingFeature,
|
|
11
|
+
sortFn_alphanumeric,
|
|
12
|
+
tableFeatures,
|
|
13
|
+
} from '@tanstack/react-table';
|
|
14
|
+
|
|
15
|
+
export const dataTableFeatures = tableFeatures({
|
|
16
|
+
columnFilteringFeature,
|
|
17
|
+
filteredRowModel: createFilteredRowModel(),
|
|
18
|
+
filterFns: { includesString: filterFn_includesString },
|
|
19
|
+
rowSortingFeature,
|
|
20
|
+
sortedRowModel: createSortedRowModel(),
|
|
21
|
+
sortFns: { alphanumeric: sortFn_alphanumeric },
|
|
22
|
+
rowPaginationFeature,
|
|
23
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
24
|
+
rowSelectionFeature,
|
|
25
|
+
columnVisibilityFeature,
|
|
26
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
interface UseInfiniteScrollOptions {
|
|
4
|
+
/** Функция загрузки следующей страницы */
|
|
5
|
+
fetchNextPage: () => void;
|
|
6
|
+
/** Есть ли ещё страницы */
|
|
7
|
+
hasNextPage: boolean;
|
|
8
|
+
/** Идёт ли загрузка */
|
|
9
|
+
isFetchingNextPage: boolean;
|
|
10
|
+
/** Отступ от края для срабатывания (default: 100px) */
|
|
11
|
+
rootMargin?: string;
|
|
12
|
+
/** Порог срабатывания (default: 0) */
|
|
13
|
+
threshold?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Хук для infinite scroll с IntersectionObserver
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const { loadMoreRef } = useInfiniteScroll({
|
|
21
|
+
* fetchNextPage,
|
|
22
|
+
* hasNextPage,
|
|
23
|
+
* isFetchingNextPage,
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* return (
|
|
27
|
+
* <div>
|
|
28
|
+
* {items.map(...)}
|
|
29
|
+
* <div ref={loadMoreRef} />
|
|
30
|
+
* </div>
|
|
31
|
+
* );
|
|
32
|
+
*/
|
|
33
|
+
export function useInfiniteScroll({
|
|
34
|
+
fetchNextPage,
|
|
35
|
+
hasNextPage,
|
|
36
|
+
isFetchingNextPage,
|
|
37
|
+
rootMargin = '100px',
|
|
38
|
+
threshold = 0,
|
|
39
|
+
}: UseInfiniteScrollOptions) {
|
|
40
|
+
const loadMoreRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
|
|
42
|
+
const handleObserver = useCallback(
|
|
43
|
+
(entries: IntersectionObserverEntry[]) => {
|
|
44
|
+
const [entry] = entries;
|
|
45
|
+
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
|
|
46
|
+
fetchNextPage();
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
[hasNextPage, isFetchingNextPage, fetchNextPage],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const element = loadMoreRef.current;
|
|
54
|
+
if (!element) return;
|
|
55
|
+
|
|
56
|
+
const observer = new IntersectionObserver(handleObserver, {
|
|
57
|
+
root: null,
|
|
58
|
+
rootMargin,
|
|
59
|
+
threshold,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
observer.observe(element);
|
|
63
|
+
return () => observer.disconnect();
|
|
64
|
+
}, [handleObserver, rootMargin, threshold]);
|
|
65
|
+
|
|
66
|
+
return { loadMoreRef };
|
|
67
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Определяет touch устройство
|
|
7
|
+
*
|
|
8
|
+
* Проверяет:
|
|
9
|
+
* - ontouchstart в window
|
|
10
|
+
* - navigator.maxTouchPoints
|
|
11
|
+
* - media query (pointer: coarse)
|
|
12
|
+
*/
|
|
13
|
+
export function useIsTouchDevice() {
|
|
14
|
+
const [isTouch, setIsTouch] = useState(false);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
setIsTouch(
|
|
18
|
+
'ontouchstart' in window ||
|
|
19
|
+
navigator.maxTouchPoints > 0 ||
|
|
20
|
+
window.matchMedia('(pointer: coarse)').matches,
|
|
21
|
+
);
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
return isTouch;
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Хук для отслеживания media query
|
|
7
|
+
*
|
|
8
|
+
* @param query - CSS media query строка (например '(min-width: 768px)')
|
|
9
|
+
* @returns true если query совпадает, false если нет или SSR
|
|
10
|
+
*/
|
|
11
|
+
export function useMediaQuery(query: string): boolean {
|
|
12
|
+
const [matches, setMatches] = useState(false);
|
|
13
|
+
const [mounted, setMounted] = useState(false);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
setMounted(true);
|
|
17
|
+
const mediaQuery = window.matchMedia(query);
|
|
18
|
+
setMatches(mediaQuery.matches);
|
|
19
|
+
|
|
20
|
+
const handler = (event: MediaQueryListEvent) => {
|
|
21
|
+
setMatches(event.matches);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
mediaQuery.addEventListener('change', handler);
|
|
25
|
+
return () => mediaQuery.removeEventListener('change', handler);
|
|
26
|
+
}, [query]);
|
|
27
|
+
|
|
28
|
+
// Return false during SSR to avoid hydration mismatch
|
|
29
|
+
return mounted ? matches : false;
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getRequestConfig } from 'next-intl/server';
|
|
2
|
+
import { defaultLocale, LocaleSchema } from './locales';
|
|
3
|
+
|
|
4
|
+
export default getRequestConfig(async ({ requestLocale }) => {
|
|
5
|
+
const requested = await requestLocale;
|
|
6
|
+
const parsedLocale = LocaleSchema.safeParse(requested);
|
|
7
|
+
const locale = parsedLocale.success ? parsedLocale.data : defaultLocale;
|
|
8
|
+
return { locale, messages: (await import(`../../messages/${locale}.json`)).default };
|
|
9
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { repositoryContract } from '@app/shared';
|
|
2
|
+
import { createClient, createHttpClient, createUrlBuilder } from 'stitchkit';
|
|
3
|
+
import { env } from '@/env';
|
|
4
|
+
|
|
5
|
+
function apiOrigin(): string {
|
|
6
|
+
return typeof window === 'undefined' ? env.INTERNAL_API_URL : env.NEXT_PUBLIC_API_URL;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createRepositoryApi(baseUrl: string) {
|
|
10
|
+
const http = createHttpClient({ baseUrl: `${baseUrl}/api`, credentials: 'omit' });
|
|
11
|
+
return createClient(repositoryContract, http);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const http = createHttpClient({ baseUrl: `${apiOrigin()}/api`, credentials: 'omit' });
|
|
15
|
+
export const repositoryApi = createRepositoryApi(apiOrigin());
|
|
16
|
+
export const repositoryUrls = createUrlBuilder(repositoryContract, http);
|
|
17
|
+
|
|
18
|
+
export function createServerRepositoryApi() {
|
|
19
|
+
return createRepositoryApi(env.INTERNAL_API_URL);
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createMutation, createQuery } from 'react-query-kit';
|
|
2
|
+
import { repositoryApi } from './client';
|
|
3
|
+
|
|
4
|
+
export const useRepository = createQuery({
|
|
5
|
+
queryKey: ['repository'],
|
|
6
|
+
fetcher: () => repositoryApi.read(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const useRefreshRepository = createMutation({
|
|
10
|
+
mutationFn: () => repositoryApi.refresh(),
|
|
11
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { isServer, QueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { cache } from 'react';
|
|
3
|
+
|
|
4
|
+
function createQueryClient(): QueryClient {
|
|
5
|
+
return new QueryClient({
|
|
6
|
+
defaultOptions: {
|
|
7
|
+
queries: { staleTime: 30_000, retry: 1 },
|
|
8
|
+
dehydrate: { shouldDehydrateQuery: (query) => query.state.status === 'pending' },
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const getServerQueryClient = cache(createQueryClient);
|
|
14
|
+
let browserQueryClient: QueryClient | undefined;
|
|
15
|
+
|
|
16
|
+
export function getQueryClient(): QueryClient {
|
|
17
|
+
if (isServer) return getServerQueryClient();
|
|
18
|
+
if (!browserQueryClient) browserQueryClient = createQueryClient();
|
|
19
|
+
return browserQueryClient;
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ClientToServerEvents, ServerToClientEvents } from '@app/shared';
|
|
2
|
+
import { createSocketIOClient } from 'stitchkit';
|
|
3
|
+
import { createCacheBridge } from 'stitchkit/react';
|
|
4
|
+
import { env } from '@/env';
|
|
5
|
+
import { useRepository } from '@/lib/api/queries';
|
|
6
|
+
import { getQueryClient } from '@/lib/query-client';
|
|
7
|
+
|
|
8
|
+
export const repositorySocket = createSocketIOClient<
|
|
9
|
+
ServerToClientEvents,
|
|
10
|
+
ClientToServerEvents
|
|
11
|
+
>({ url: env.NEXT_PUBLIC_API_URL });
|
|
12
|
+
|
|
13
|
+
export const repositoryBridge = createCacheBridge({
|
|
14
|
+
socket: repositorySocket,
|
|
15
|
+
queryClient: getQueryClient,
|
|
16
|
+
handlers: {
|
|
17
|
+
'repository:refreshed': (snapshot, { queryClient }) => {
|
|
18
|
+
queryClient.setQueryData(useRepository.getKey(), snapshot);
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { env } from '@/env';
|
|
3
|
+
import type { AppLocale } from '@/i18n/locales';
|
|
4
|
+
import { locales } from '@/i18n/locales';
|
|
5
|
+
import type { SeoPageId } from './pages';
|
|
6
|
+
import { getSeoPage, localizedPagePath, SITE_NAME } from './pages';
|
|
7
|
+
|
|
8
|
+
export const siteOrigin = new URL(env.NEXT_PUBLIC_WEB_URL).origin;
|
|
9
|
+
|
|
10
|
+
export function absoluteSiteUrl(path: string): string {
|
|
11
|
+
return new URL(path, siteOrigin).toString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createPageMetadata(pageId: SeoPageId, locale: AppLocale): Metadata {
|
|
15
|
+
const page = getSeoPage(pageId, locale);
|
|
16
|
+
const title = pageId === 'home' ? SITE_NAME : `${page.title} · ${SITE_NAME}`;
|
|
17
|
+
const canonicalPath = localizedPagePath(pageId, locale);
|
|
18
|
+
const image = absoluteSiteUrl(`/api/og/${locale}/${pageId}`);
|
|
19
|
+
const languageAlternates = Object.fromEntries(
|
|
20
|
+
locales.map((availableLocale) => [
|
|
21
|
+
availableLocale,
|
|
22
|
+
absoluteSiteUrl(localizedPagePath(pageId, availableLocale)),
|
|
23
|
+
]),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
title,
|
|
28
|
+
description: page.description,
|
|
29
|
+
alternates: {
|
|
30
|
+
canonical: absoluteSiteUrl(canonicalPath),
|
|
31
|
+
languages: languageAlternates,
|
|
32
|
+
},
|
|
33
|
+
openGraph: {
|
|
34
|
+
type: 'website',
|
|
35
|
+
siteName: SITE_NAME,
|
|
36
|
+
title,
|
|
37
|
+
description: page.description,
|
|
38
|
+
url: absoluteSiteUrl(canonicalPath),
|
|
39
|
+
locale: locale === 'ru' ? 'ru_RU' : 'en_US',
|
|
40
|
+
alternateLocale: locale === 'ru' ? ['en_US'] : ['ru_RU'],
|
|
41
|
+
images: [{ url: image, width: 1200, height: 630, alt: `${title} — ${page.eyebrow}` }],
|
|
42
|
+
},
|
|
43
|
+
twitter: {
|
|
44
|
+
card: 'summary_large_image',
|
|
45
|
+
title,
|
|
46
|
+
description: page.description,
|
|
47
|
+
images: [{ url: image, alt: `${title} — ${page.eyebrow}` }],
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { LocaleSchema } from '@/i18n/locales';
|
|
3
|
+
import { getSeoPage, localizedPagePath, publicPageIds } from './pages';
|
|
4
|
+
|
|
5
|
+
describe('SEO page registry', () => {
|
|
6
|
+
test('defines complete localized copy for every public page', () => {
|
|
7
|
+
for (const locale of LocaleSchema.options) {
|
|
8
|
+
const titles = new Set<string>();
|
|
9
|
+
const descriptions = new Set<string>();
|
|
10
|
+
|
|
11
|
+
for (const pageId of publicPageIds) {
|
|
12
|
+
const page = getSeoPage(pageId, locale);
|
|
13
|
+
expect(page.title.length).toBeGreaterThan(10);
|
|
14
|
+
expect(page.description.length).toBeGreaterThan(60);
|
|
15
|
+
expect(page.eyebrow.length).toBeGreaterThan(0);
|
|
16
|
+
expect(localizedPagePath(pageId, locale)).toStartWith(`/${locale}`);
|
|
17
|
+
titles.add(page.title);
|
|
18
|
+
descriptions.add(page.description);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
expect(titles.size).toBe(publicPageIds.length);
|
|
22
|
+
expect(descriptions.size).toBe(publicPageIds.length);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { AppLocale } from '@/i18n/locales';
|
|
3
|
+
|
|
4
|
+
export const SITE_NAME = 'Stitchkit Starter';
|
|
5
|
+
export const StoryIdSchema = z.enum(['components', 'themes', 'blocks']);
|
|
6
|
+
export type StoryId = z.infer<typeof StoryIdSchema>;
|
|
7
|
+
|
|
8
|
+
export const SeoPageIdSchema = z.enum(['home', ...StoryIdSchema.options]);
|
|
9
|
+
export type SeoPageId = z.infer<typeof SeoPageIdSchema>;
|
|
10
|
+
|
|
11
|
+
interface SeoCopy {
|
|
12
|
+
title: string;
|
|
13
|
+
description: string;
|
|
14
|
+
eyebrow: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SeoPageDefinition {
|
|
18
|
+
path: string;
|
|
19
|
+
copy: Record<AppLocale, SeoCopy>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const seoPages: Record<SeoPageId, SeoPageDefinition> = {
|
|
23
|
+
home: {
|
|
24
|
+
path: '',
|
|
25
|
+
copy: {
|
|
26
|
+
en: {
|
|
27
|
+
title: 'Production-ready full-stack starter',
|
|
28
|
+
description:
|
|
29
|
+
'Build typed production applications with Stitchkit, Next.js, Bun, PostgreSQL, realtime cache updates and contract-driven tools.',
|
|
30
|
+
eyebrow: 'One contract. Every surface.',
|
|
31
|
+
},
|
|
32
|
+
ru: {
|
|
33
|
+
title: 'Production-ready full-stack стартер',
|
|
34
|
+
description:
|
|
35
|
+
'Создавайте типизированные production-приложения на Stitchkit, Next.js, Bun и PostgreSQL с realtime-кешем и contract-driven tools.',
|
|
36
|
+
eyebrow: 'Один контракт. Все поверхности.',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
components: {
|
|
41
|
+
path: '/ui/components',
|
|
42
|
+
copy: {
|
|
43
|
+
en: {
|
|
44
|
+
title: 'UI components',
|
|
45
|
+
description:
|
|
46
|
+
'Explore accessible actions, forms, feedback, overlays, data displays and motion components included in the Stitchkit starter.',
|
|
47
|
+
eyebrow: 'UI system',
|
|
48
|
+
},
|
|
49
|
+
ru: {
|
|
50
|
+
title: 'UI-компоненты',
|
|
51
|
+
description:
|
|
52
|
+
'Изучите доступные действия, формы, обратную связь, оверлеи, представление данных и анимации в Stitchkit Starter.',
|
|
53
|
+
eyebrow: 'UI-система',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
themes: {
|
|
58
|
+
path: '/ui/themes',
|
|
59
|
+
copy: {
|
|
60
|
+
en: {
|
|
61
|
+
title: 'Theme system',
|
|
62
|
+
description:
|
|
63
|
+
'Global, system, server-readable, forced and scoped themes with configurable View Transition effects.',
|
|
64
|
+
eyebrow: 'UI system',
|
|
65
|
+
},
|
|
66
|
+
ru: {
|
|
67
|
+
title: 'Система тем',
|
|
68
|
+
description:
|
|
69
|
+
'Глобальные, системные, server-readable, forced и scoped темы с настраиваемыми View Transition эффектами.',
|
|
70
|
+
eyebrow: 'UI-система',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
blocks: {
|
|
75
|
+
path: '/ui/blocks',
|
|
76
|
+
copy: {
|
|
77
|
+
en: {
|
|
78
|
+
title: 'Reusable UI blocks',
|
|
79
|
+
description:
|
|
80
|
+
'Production-shaped landing, application shell, account and checkout blocks composed from the starter components.',
|
|
81
|
+
eyebrow: 'UI system',
|
|
82
|
+
},
|
|
83
|
+
ru: {
|
|
84
|
+
title: 'Готовые UI-блоки',
|
|
85
|
+
description:
|
|
86
|
+
'Production-shaped лендинг, каркас приложения, аккаунт и checkout-блоки, собранные из компонентов стартера.',
|
|
87
|
+
eyebrow: 'UI-система',
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const publicPageIds = SeoPageIdSchema.options;
|
|
94
|
+
export const storyIds = StoryIdSchema.options;
|
|
95
|
+
|
|
96
|
+
export function isStoryId(value: string): value is StoryId {
|
|
97
|
+
return StoryIdSchema.safeParse(value).success;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getSeoPage(pageId: SeoPageId, locale: AppLocale) {
|
|
101
|
+
const page = seoPages[pageId];
|
|
102
|
+
return { ...page, ...page.copy[locale] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function localizedPagePath(pageId: SeoPageId, locale: AppLocale): string {
|
|
106
|
+
return `/${locale}${seoPages[pageId].path}`;
|
|
107
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { formatDistanceToNow } from 'date-fns';
|
|
2
|
+
import { enUS, ru } from 'date-fns/locale';
|
|
3
|
+
|
|
4
|
+
export type Currency = 'EUR' | 'RUB' | 'USD';
|
|
5
|
+
|
|
6
|
+
const currencySymbols: Record<Currency, string> = { EUR: '€', RUB: '₽', USD: '$' };
|
|
7
|
+
|
|
8
|
+
export function formatPrice(amount: number, currency: Currency): string {
|
|
9
|
+
return `${currencySymbols[currency]}${Math.round(amount).toLocaleString()}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatDateTime(value: Date | string): { date: string; time: string } {
|
|
13
|
+
const date = typeof value === 'string' ? new Date(value) : value;
|
|
14
|
+
return {
|
|
15
|
+
date: new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(date),
|
|
16
|
+
time: new Intl.DateTimeFormat('en', { timeStyle: 'short' }).format(date),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatRelativeTime(date: string, locale: string): string {
|
|
21
|
+
return formatDistanceToNow(new Date(date), {
|
|
22
|
+
addSuffix: true,
|
|
23
|
+
locale: locale === 'ru' ? ru : enUS,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import { Toaster } from '@/components/ui/toaster';
|
|
6
|
+
import { ReactQueryProvider } from './react-query';
|
|
7
|
+
import { RealtimeProvider } from './realtime';
|
|
8
|
+
|
|
9
|
+
export function Providers({ children }: { children: ReactNode }) {
|
|
10
|
+
return (
|
|
11
|
+
<ReactQueryProvider>
|
|
12
|
+
<RealtimeProvider>
|
|
13
|
+
<TooltipProvider delayDuration={250}>{children}</TooltipProvider>
|
|
14
|
+
</RealtimeProvider>
|
|
15
|
+
<Toaster />
|
|
16
|
+
</ReactQueryProvider>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import { getQueryClient } from '@/lib/query-client';
|
|
6
|
+
|
|
7
|
+
export function ReactQueryProvider({ children }: { children: ReactNode }) {
|
|
8
|
+
return <QueryClientProvider client={getQueryClient()}>{children}</QueryClientProvider>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useEffect } from 'react';
|
|
4
|
+
import { repositoryBridge, repositorySocket } from '@/lib/realtime/repository';
|
|
5
|
+
|
|
6
|
+
export function RealtimeProvider({ children }: { children: ReactNode }) {
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
repositorySocket.connect();
|
|
9
|
+
repositoryBridge.connect();
|
|
10
|
+
return () => {
|
|
11
|
+
repositoryBridge.disconnect();
|
|
12
|
+
repositorySocket.disconnect();
|
|
13
|
+
};
|
|
14
|
+
}, []);
|
|
15
|
+
return children;
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ThemeProviderProps } from '@wrksz/themes/next';
|
|
2
|
+
|
|
3
|
+
export type AppTheme = 'light' | 'dark';
|
|
4
|
+
|
|
5
|
+
export const appThemes: readonly [AppTheme, AppTheme] = ['light', 'dark'];
|
|
6
|
+
export const themeStorageKey = 'stitchkit-starter-theme';
|
|
7
|
+
|
|
8
|
+
export const themeProviderConfig = {
|
|
9
|
+
themes: appThemes,
|
|
10
|
+
attribute: 'class',
|
|
11
|
+
defaultTheme: 'system',
|
|
12
|
+
enableSystem: true,
|
|
13
|
+
enableColorScheme: true,
|
|
14
|
+
storage: 'hybrid',
|
|
15
|
+
storageKey: themeStorageKey,
|
|
16
|
+
themeColor: {
|
|
17
|
+
light: '#ffffff',
|
|
18
|
+
dark: '#242428',
|
|
19
|
+
},
|
|
20
|
+
} satisfies Omit<ThemeProviderProps<AppTheme>, 'children'>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type ThemeTransitionStyle = 'crossfade' | 'radial';
|
|
2
|
+
|
|
3
|
+
export interface ThemeTransitionConfig {
|
|
4
|
+
style: ThemeTransitionStyle;
|
|
5
|
+
durationMs: number;
|
|
6
|
+
easing: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ThemeTransitionOrigin {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const defaultThemeTransition = {
|
|
15
|
+
style: 'crossfade',
|
|
16
|
+
durationMs: 250,
|
|
17
|
+
easing: 'ease-out',
|
|
18
|
+
} satisfies ThemeTransitionConfig;
|
|
19
|
+
|
|
20
|
+
export function runThemeTransition(
|
|
21
|
+
updateTheme: () => void,
|
|
22
|
+
config: ThemeTransitionConfig = defaultThemeTransition,
|
|
23
|
+
origin?: ThemeTransitionOrigin,
|
|
24
|
+
) {
|
|
25
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
26
|
+
if (typeof document.startViewTransition !== 'function' || reducedMotion) {
|
|
27
|
+
updateTheme();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const root = document.documentElement;
|
|
32
|
+
const transitionOrigin = origin ?? {
|
|
33
|
+
x: window.innerWidth / 2,
|
|
34
|
+
y: window.innerHeight / 2,
|
|
35
|
+
};
|
|
36
|
+
root.classList.add('theme-transitioning');
|
|
37
|
+
root.dataset.themeTransition = config.style;
|
|
38
|
+
root.style.setProperty('--theme-transition-duration', `${config.durationMs}ms`);
|
|
39
|
+
root.style.setProperty('--theme-transition-easing', config.easing);
|
|
40
|
+
root.style.setProperty('--theme-transition-x', `${transitionOrigin.x}px`);
|
|
41
|
+
root.style.setProperty('--theme-transition-y', `${transitionOrigin.y}px`);
|
|
42
|
+
|
|
43
|
+
const cleanup = () => {
|
|
44
|
+
root.classList.remove('theme-transitioning');
|
|
45
|
+
delete root.dataset.themeTransition;
|
|
46
|
+
root.style.removeProperty('--theme-transition-duration');
|
|
47
|
+
root.style.removeProperty('--theme-transition-easing');
|
|
48
|
+
root.style.removeProperty('--theme-transition-x');
|
|
49
|
+
root.style.removeProperty('--theme-transition-y');
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const transition = document.startViewTransition(updateTheme);
|
|
53
|
+
transition.finished.then(cleanup, cleanup);
|
|
54
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
|
5
|
+
"jsx": "react-jsx",
|
|
6
|
+
"incremental": true,
|
|
7
|
+
"types": ["bun"],
|
|
8
|
+
"plugins": [{ "name": "next" }],
|
|
9
|
+
"paths": { "@/*": ["./src/*"] }
|
|
10
|
+
},
|
|
11
|
+
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
|
|
12
|
+
"exclude": ["node_modules"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@app/shared",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"check": "bun x tsc --noEmit",
|
|
12
|
+
"test": "bun test --pass-with-no-tests"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@app/db": "workspace:*",
|
|
16
|
+
"stitchkit": "catalog:",
|
|
17
|
+
"zod": "^4.4.3"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/bun": "^1.3.14",
|
|
21
|
+
"typescript": "^7.0.2"
|
|
22
|
+
}
|
|
23
|
+
}
|