Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/app/[locale]/error.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter } from '@/i18n/routing';
import { useTranslations } from 'next-intl';

import { PageHeader } from '@/shared/ui/page-header';
Expand Down
7 changes: 3 additions & 4 deletions web/src/app/[locale]/posts/[categoryUrl]/PostActions.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter } from '@/i18n/routing';
import { useTranslations } from 'next-intl';

import { ButtonGroup } from '@/shared/ui/button-group';
Expand All @@ -20,12 +20,11 @@ import {

interface PostActionsProps {
post: Post;
locale: string;
isEditing: boolean;
onToggleEdit: () => void;
}

export function PostActions({ post, locale, isEditing, onToggleEdit }: PostActionsProps) {
export function PostActions({ post, isEditing, onToggleEdit }: PostActionsProps) {
const router = useRouter();
const tSystem = useTranslations('system');
const tPosts = useTranslations('posts');
Expand Down Expand Up @@ -67,7 +66,7 @@ export function PostActions({ post, locale, isEditing, onToggleEdit }: PostActio
success(tSystem('deleted'));
setDeleteOpen(false);
startTransition(() => {
router.push(locale ? `/${locale}/posts` : '/posts');
router.push('/posts');
router.refresh();
});
} catch (err) {
Expand Down
21 changes: 15 additions & 6 deletions web/src/app/[locale]/posts/[categoryUrl]/PostDetailClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

import { useEffect, useMemo, useState, useTransition } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Link, useRouter } from '@/i18n/routing';
import { useTranslations } from 'next-intl';

import { PageHeader } from '@/shared/ui/page-header';
Expand Down Expand Up @@ -194,7 +193,10 @@ export function PostDetailClient({
});
success(tSystem('saved'));
startTransition(() => {
router.push(locale ? `/${locale}/posts/${created.url}` : `/posts/${created.url}`);
router.push({
pathname: '/posts/[categoryUrl]',
params: { categoryUrl: created.url },
});
router.refresh();
});
return;
Expand Down Expand Up @@ -224,7 +226,12 @@ export function PostDetailClient({
setEditing(false);
};

const categoryLink = post.category_data ? `/posts/${post.category_data.url}` : undefined;
const categoryLink = post.category_data
? ({
pathname: '/posts/[categoryUrl]',
params: { categoryUrl: post.category_data.url },
} as const)
: undefined;
const imageValue = image || post.image || '';
const imageFileData = imageValue && fileData
? { ...fileData, type: 'image' as const }
Expand All @@ -236,7 +243,6 @@ export function PostDetailClient({
: (
<PostActions
post={post}
locale={locale}
isEditing={isEditing}
onToggleEdit={() => {
if (isEditing) {
Expand Down Expand Up @@ -286,7 +292,10 @@ export function PostDetailClient({

{post.category_data && (
<Link
href={`/posts/${post.category_data.url}`}
href={{
pathname: '/posts/[categoryUrl]',
params: { categoryUrl: post.category_data.url },
}}
className="flex items-center justify-between gap-3 rounded-[0.75rem] bg-muted/60 px-3 py-2 transition-all duration-300 ease-[cubic-bezier(0,0,0.5,1)] hover:scale-[1.01]"
>
<div className="flex items-center gap-3">
Expand Down
86 changes: 81 additions & 5 deletions web/src/i18n/routing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { ComponentProps } from 'react';
import { createElement, useCallback, useMemo } from 'react';
import { defineRouting } from 'next-intl/routing';
import { createNavigation } from 'next-intl/navigation';
import { appendVkLaunchParamsToUrl, getVkLaunchQuery } from '@/shared/lib/vk';

export type Locale = 'en' | 'ru' | 'zh' | 'es' | 'ar';

Expand All @@ -16,7 +19,7 @@ export const routing = defineRouting({
'/posts': '/posts',
'/posts/[categoryUrl]': '/posts/[categoryUrl]',
'/catalog/[id]': '/catalog/[id]',
'/space': '/space',
'/space': '/space',
'/hub': '/hub',
'/tasks': '/tasks',
'/catalog': '/catalog',
Expand Down Expand Up @@ -46,7 +49,80 @@ export const routing = defineRouting({
localeDetection: true
});

// Lightweight wrappers around Next.js' navigation APIs
// that will consider the routing configuration
export const { Link, redirect, usePathname, useRouter } =
createNavigation(routing);
const navigation = createNavigation(routing);

export const { redirect, usePathname } = navigation;

type AppRouterInstance = ReturnType<typeof navigation.useRouter>;
export type RouteHref = Parameters<AppRouterInstance['push']>[0];
type RoutePath = Extract<RouteHref, string>;
type HashHref = `${RoutePath}#${string}`;
type LinkHref = RouteHref | HashHref;

const mergeVkQuery = (href: RouteHref, vkQuery: Record<string, string> | null): RouteHref => {
if (!vkQuery || Object.keys(vkQuery).length === 0) {
return href;
}

if (typeof href === 'string') {
return appendVkLaunchParamsToUrl(href, vkQuery) as RouteHref;
}

return {
...href,
query: { ...vkQuery, ...(href.query ?? {}) },
};
};

const mergeVkQueryForLink = (href: LinkHref, vkQuery: Record<string, string> | null): LinkHref => {
if (!vkQuery || Object.keys(vkQuery).length === 0) {
return href;
}

if (typeof href === 'string') {
return appendVkLaunchParamsToUrl(href, vkQuery) as LinkHref;
}

return {
...href,
query: { ...vkQuery, ...(href.query ?? {}) },
};
};

// VK Mini App navigation requires keeping launch params in the URL.
export const useRouter = () => {
const router = navigation.useRouter();
const vkQuery = useMemo(() => getVkLaunchQuery(), []);

const push = useCallback<AppRouterInstance['push']>(
(href, options) => router.push(mergeVkQuery(href, vkQuery), options),
[router, vkQuery]
);

const replace = useCallback<AppRouterInstance['replace']>(
(href, options) => router.replace(mergeVkQuery(href, vkQuery), options),
[router, vkQuery]
);

return useMemo(
() => ({
...router,
push,
replace,
}),
[push, replace, router]
);
};

type LinkProps = Omit<ComponentProps<typeof navigation.Link>, 'href'> & {
href: LinkHref;
};

export const Link = ({ href, ...props }: LinkProps) => {
const vkQuery = getVkLaunchQuery();
const mergedHref = mergeVkQueryForLink(href, vkQuery) as ComponentProps<typeof navigation.Link>['href'];
return createElement(navigation.Link, {
...props,
href: mergedHref,
});
};
2 changes: 2 additions & 0 deletions web/src/shared/components/layout/ThemeAwareContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { useTheme } from '@/providers/ThemeProvider';
import LoadingScreen from './LoadingScreen';
import { Header, MobileBottomBar } from '@/widgets/header';
import { VkLaunchParamsSync } from '@/shared/components/layout';
import { Footer } from '@/widgets/footer';
import { useAppDispatch, useAppSelector } from '@/shared/stores/store';
import { selectIsApp, setIsApp } from '@/shared/stores/layoutSlice';
Expand All @@ -30,6 +31,7 @@ export default function ThemeAwareContent({ children }: ThemeAwareContentProps)

return (
<LoadingScreen isLoading={!isInitialized}>
<VkLaunchParamsSync />
{shouldUseAppShell ? null : <Header />}
<div
className={cn(
Expand Down
27 changes: 27 additions & 0 deletions web/src/shared/components/layout/VkLaunchParamsSync.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client';

import { useEffect } from 'react';
import { usePathname } from '@/i18n/routing';
import { appendVkLaunchParamsToUrl, getVkLaunchQuery } from '@/shared/lib/vk';

export default function VkLaunchParamsSync() {
const pathname = usePathname();

useEffect(() => {
const vkQuery = getVkLaunchQuery();
if (!vkQuery) return;

const anchors = document.querySelectorAll<HTMLAnchorElement>('a[href]');
anchors.forEach((anchor) => {
const href = anchor.getAttribute('href');
if (!href) return;

const updated = appendVkLaunchParamsToUrl(href, vkQuery);
if (updated !== href) {
anchor.setAttribute('href', updated);
}
});
}, [pathname]);

return null;
}
1 change: 1 addition & 0 deletions web/src/shared/components/layout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { default as ThemeSwitcher } from './ThemeSwitcher';
export { default as ThemeAwareContent } from './ThemeAwareContent';
export { default as StructuredData } from './StructuredData';
export { default as VkBridgeInitializer } from './VkBridgeInitializer';
export { default as VkLaunchParamsSync } from './VkLaunchParamsSync';
39 changes: 39 additions & 0 deletions web/src/shared/lib/vk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,45 @@ const readStoredVkLaunchParams = () => {
}
};

const shouldSkipHref = (href: string) => {
const lower = href.toLowerCase();
return (
lower.startsWith('mailto:') ||
lower.startsWith('tel:') ||
lower.startsWith('javascript:') ||
lower.startsWith('#')
);
};

export const appendVkLaunchParamsToUrl = (
href: string,
vkQuery?: Record<string, string> | null
) => {
if (!vkQuery || Object.keys(vkQuery).length === 0) return href;
if (typeof window === 'undefined') return href;
if (!href || shouldSkipHref(href)) return href;

let url: URL;
try {
url = new URL(href, window.location.href);
} catch {
return href;
}

if (url.origin !== window.location.origin) {
return href;
}

Object.entries(vkQuery).forEach(([key, value]) => {
if (!url.searchParams.has(key)) {
url.searchParams.set(key, value);
}
});

const search = url.searchParams.toString();
return `${url.pathname}${search ? `?${search}` : ''}${url.hash || ''}`;
};

export const getVkLaunchParams = () => {
const params = getVkQueryParams();
if (isValidVkLaunchParams(params)) {
Expand Down
17 changes: 14 additions & 3 deletions web/src/shared/ui/breadcrumb-description.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from "react"
import Link from 'next/link'
import { Link } from '@/i18n/routing'

interface BreadcrumbItem {
id: number
Expand All @@ -15,13 +15,24 @@ interface BreadcrumbDescriptionProps {
export function BreadcrumbDescription({ breadcrumbs }: BreadcrumbDescriptionProps) {
if (breadcrumbs.length <= 1) return null

const resolveBreadcrumbHref = (url: string) => {
if (url === '/posts') {
return '/posts' as const
}
const slug = url.replace(/^\/posts\//, '')
return {
pathname: '/posts/[categoryUrl]',
params: { categoryUrl: slug }
} as const
}

return (
<span className="flex items-center flex-wrap gap-1">
{breadcrumbs.slice(0, -1).map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.id}>
{index > 0 && <span className="text-muted-foreground/50">/</span>}
<Link
href={breadcrumb.url}
href={resolveBreadcrumbHref(breadcrumb.url)}
className="text-muted-foreground hover:text-foreground underline decoration-dashed decoration-1 underline-offset-2 transition-colors"
title={`Go to ${breadcrumb.title}`}
>
Expand All @@ -31,4 +42,4 @@ export function BreadcrumbDescription({ breadcrumbs }: BreadcrumbDescriptionProp
))}
</span>
)
}
}
4 changes: 2 additions & 2 deletions web/src/shared/ui/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import * as React from 'react';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Link, type RouteHref } from '@/i18n/routing';
import { cva, type VariantProps } from 'class-variance-authority';

import { cn } from '@/shared/lib/utils';
Expand Down Expand Up @@ -221,7 +221,7 @@ interface CardProps extends VariantProps<typeof cardVariants> {
images: string[];

// Navigation
href?: string;
href?: RouteHref;
onClick?: () => void;

// Filters above title (plain icon + value)
Expand Down
20 changes: 13 additions & 7 deletions web/src/shared/ui/entity-management.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import Link from 'next/link';
import { Link, type RouteHref } from '@/i18n/routing';
import { ReactNode } from 'react';
import { Alert, AlertDescription } from './alert';
import { Box } from './box';
Expand Down Expand Up @@ -79,6 +79,7 @@ interface EntityRowProps {
id?: number | string;
title: string;
url?: string;
urlHref?: RouteHref;
badges?: ReactNode[];
secondRowItems?: EntityRowMetaItem[];
leftSlot?: ReactNode;
Expand All @@ -94,6 +95,7 @@ export function EntityRow({
id,
title,
url,
urlHref,
badges = [],
secondRowItems = [],
leftSlot,
Expand All @@ -113,12 +115,16 @@ export function EntityRow({
) : null}
<span className="font-medium truncate">{title}</span>
{url ? (
<Link
href={`/${url}`}
className="text-xs text-muted-foreground hover:text-primary transition-colors underline decoration-dashed underline-offset-2"
>
/{url}
</Link>
urlHref ? (
<Link
href={urlHref}
className="text-xs text-muted-foreground hover:text-primary transition-colors underline decoration-dashed underline-offset-2"
>
/{url}
</Link>
) : (
<span className="text-xs text-muted-foreground">/{url}</span>
)
) : null}
{badges.length > 0 ? <div className="flex flex-wrap gap-2">{badges}</div> : null}
</div>
Expand Down
Loading