diff --git a/components/Layout/MainNavigator.tsx b/components/Layout/MainNavigator.tsx index 8bff0fc..c5bc070 100644 --- a/components/Layout/MainNavigator.tsx +++ b/components/Layout/MainNavigator.tsx @@ -1,9 +1,8 @@ import { observable } from 'mobx'; import { observer } from 'mobx-react'; import { ObservedComponent } from 'mobx-react-helper'; -import Link from 'next/link'; - import { i18n, I18nContext, LanguageName } from '../../models/Translation'; +import { TransitionLink } from '../TransitionLink'; import { SymbolIcon } from '../Icon'; import { Button } from '../ui/button'; import { @@ -28,9 +27,9 @@ export class MainNavigator extends ObservedComponent<{ menu: MenuLink[] }, typeo renderLinks = () => this.props.menu.map(({ title, href, target }) => ( )); @@ -83,18 +82,18 @@ export class MainNavigator extends ObservedComponent<{ menu: MenuLink[] }, typeo - + idea2app - +
{this.renderI18nSwitch()} diff --git a/components/Layout/Section.tsx b/components/Layout/Section.tsx index 163045a..4771ab0 100644 --- a/components/Layout/Section.tsx +++ b/components/Layout/Section.tsx @@ -1,8 +1,8 @@ import { observer } from 'mobx-react'; -import Link from 'next/link'; import { FC, PropsWithChildren, useContext } from 'react'; import { I18nContext } from '../../models/Translation'; +import { TransitionLink } from '../TransitionLink'; import { Button } from '../ui/button'; export type SectionProps = PropsWithChildren< @@ -24,9 +24,9 @@ export const Section: FC = observer( {link && (
)} diff --git a/components/Member/Card.tsx b/components/Member/Card.tsx index fed8fc7..ac1bd13 100644 --- a/components/Member/Card.tsx +++ b/components/Member/Card.tsx @@ -1,10 +1,10 @@ import { marked } from 'marked'; import { observer } from 'mobx-react'; -import Link from 'next/link'; import { ComponentProps, FC } from 'react'; import { Member } from '../../models/Member'; import { GithubIcon } from '../Layout/Svg'; +import { TransitionLink } from '../TransitionLink'; import { Badge } from '../ui/badge'; import { Button } from '../ui/button'; import { Card } from '../ui/card'; @@ -32,14 +32,14 @@ export const MemberCard: FC = observer(
{github && ( - + {String(github)} - + )}

{String(nickname)}

diff --git a/components/Project/NewCard.tsx b/components/Project/NewCard.tsx index 48b6690..5092e89 100644 --- a/components/Project/NewCard.tsx +++ b/components/Project/NewCard.tsx @@ -1,10 +1,10 @@ import { Project } from '@idea2app/data-server'; import { observer } from 'mobx-react'; -import Link from 'next/link'; import { FC, useContext } from 'react'; import { I18nContext } from '../../models/Translation'; import type zhCN from '../../translation/zh-CN'; +import { TransitionLink } from '../TransitionLink'; import { Badge } from '../ui/badge'; import { Button } from '../ui/button'; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '../ui/card'; @@ -43,7 +43,9 @@ export const ProjectCard: FC = observer(({ id, name, status = 0 }) => { diff --git a/components/TransitionLink.tsx b/components/TransitionLink.tsx new file mode 100644 index 0000000..7d91a0b --- /dev/null +++ b/components/TransitionLink.tsx @@ -0,0 +1,79 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { ComponentProps, MouseEvent } from 'react'; + +type ViewTransitionDocument = Document & { + startViewTransition?: (updateCallback: () => Promise | unknown) => void; +}; + +type TransitionLinkProps = Omit, 'href'> & { + href: string; +}; + +export function TransitionLink({ + href, + replace, + scroll, + shallow, + locale, + onClickCapture, + ...props +}: TransitionLinkProps) { + const router = useRouter(); + + const handleClickCapture = (event: MouseEvent) => { + onClickCapture?.(event); + + if (event.defaultPrevented) return; + + if ( + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + props.target || + typeof href !== 'string' || + !href.startsWith('/') + ) + return; + + const documentWithTransition = document as ViewTransitionDocument; + + if (!documentWithTransition.startViewTransition) return; + + const nextURL = new URL(href, location.href), + currentURL = new URL(router.asPath, location.href); + + const isHashOnlyNavigation = + nextURL.pathname === currentURL.pathname && + nextURL.search === currentURL.search && + nextURL.hash !== currentURL.hash; + + if (isHashOnlyNavigation || nextURL.href === currentURL.href) + return; + + event.preventDefault(); + + const navigate = () => + replace + ? router.replace(href, undefined, { scroll, shallow, locale }) + : router.push(href, undefined, { scroll, shallow, locale }); + + documentWithTransition.startViewTransition(() => navigate()); + }; + + return ( + + {props.children} + + ); +} diff --git a/pages/_document.tsx b/pages/_document.tsx index 1a684cb..646a401 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -26,6 +26,29 @@ const organizationJsonLd = { email: 'contact@idea2app.cn', }; +const speculationRules = { + prerender: [ + { + where: { + or: [{ href_matches: '/member*' }, { href_matches: '/open-source*' }], + }, + eagerness: 'moderate', + }, + ], + prefetch: [ + { + where: { + or: [ + { href_matches: '/member*' }, + { href_matches: '/open-source*' }, + { href_matches: '/project*' }, + ], + }, + eagerness: 'moderate', + }, + ], +}; + interface CustomDocumentProps { language: LanguageCode; colorScheme: ColorScheme; @@ -67,6 +90,10 @@ export default class CustomDocument extends Document { rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&icon_names=attach_file_add,chat,code,dark_mode,diversity_3,fingerprint,key,keyboard_arrow_down,language,light_mode,menu,translate,trending_up,visibility&display=swap" /> + diff --git a/pages/index.tsx b/pages/index.tsx index 5c6501d..4d423ab 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -5,6 +5,7 @@ import { FC, useContext } from 'react'; import { Minute, Second } from 'web-utility'; import { Button } from '@/components/ui/button'; +import { TransitionLink } from '@/components/TransitionLink'; import { PartnerOverview } from '../components/Client/Partner'; import { GitListLayout } from '../components/Git'; import { SymbolIcon } from '../components/Icon'; @@ -47,18 +48,21 @@ export const getStaticProps: GetStaticProps = async () => { const HomePage: FC = observer(({ repositories, members }) => { const i18n = useContext(I18nContext); - const { t } = i18n; + const { t } = i18n, + partners = PARTNERS_INFO(i18n); return (
-
- - +
+
+ + +
-
+

{t('idea2app_summary')}

{t('idea2app_slogan')}

@@ -70,7 +74,7 @@ const HomePage: FC = observer(({ repositories, members }) => { {service(i18n).map(({ title, summary, icon, buttonText, buttonLink }) => (
  • @@ -80,13 +84,13 @@ const HomePage: FC = observer(({ repositories, members }) => {

    {summary}

  • ))} @@ -95,26 +99,28 @@ const HomePage: FC = observer(({ repositories, members }) => {
    -
      - {/** - * @todo: polish the carousel animation - */} - {Array.from({ length: 3 }).map((_, index) => ( -
    • - {PARTNERS_INFO(i18n).map(({ name, ...rest }) => ( - - ))} -
    • - ))} -
    +
    +
      + {/** + * @todo: polish the carousel animation + */} + {Array.from({ length: 2 }).map((_, index) => ( +
    • + {partners.map(({ name, ...rest }) => ( + + ))} +
    • + ))} +
    +
    -
    +
      {members.map(item => (
    • @@ -127,7 +133,9 @@ const HomePage: FC = observer(({ repositories, members }) => {
    - +
    + +
    ); diff --git a/styles/main.css b/styles/main.css index 5494951..9762019 100644 --- a/styles/main.css +++ b/styles/main.css @@ -30,4 +30,106 @@ div:has(.next-error-h1) { @utility scrollbar-none { scrollbar-width: none; -} \ No newline at end of file +} + +@media (prefers-reduced-motion: no-preference) { + html { + scroll-behavior: smooth; + } + + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: 0.35s; + animation-timing-function: ease; + } + + ::view-transition-old(root) { + animation-name: fade-out; + } + + ::view-transition-new(root) { + animation-name: fade-in; + } + + .landing-hero-logo, + .landing-hero-copy, + .landing-service-card, + .landing-section { + animation: rise-in 0.7s ease both; + } + + .landing-hero-copy { + animation-delay: 0.08s; + } + + .landing-service-card:nth-child(2) { + animation-delay: 0.12s; + } + + .landing-service-card:nth-child(3) { + animation-delay: 0.18s; + } + + .partner-marquee-track { + animation: carousel-scroll 24s linear infinite; + } + + .partner-marquee:hover .partner-marquee-track { + animation-play-state: paused; + } + + .landing-service-card { + transition: + transform 0.25s ease, + box-shadow 0.25s ease, + background-color 0.25s ease; + } + + .landing-service-card:hover { + transform: translateY(-0.375rem); + box-shadow: 0 1rem 2.5rem color-mix(in srgb, currentColor 10%, transparent); + } + +} + +@keyframes rise-in { + from { + opacity: 0; + transform: translateY(1.5rem); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes carousel-scroll { + from { + transform: translateX(0); + } + + to { + transform: translateX(-50%); + } +}