From 3d96ce2758258d45e0eeddff9bfd7515caf22795 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith <henry@catalinismith.com> Date: Sun, 26 May 2024 19:24:04 +0200 Subject: [PATCH] Create home page --- webapp/src/app/page.tsx | 57 ++++++++ webapp/src/components/HomeDashboard.tsx | 46 +++++++ webapp/src/components/ProjectCard.tsx | 172 ++++++++++++++++++++++++ 3 files changed, 275 insertions(+) create mode 100644 webapp/src/app/page.tsx create mode 100644 webapp/src/components/HomeDashboard.tsx create mode 100644 webapp/src/components/ProjectCard.tsx diff --git a/webapp/src/app/page.tsx b/webapp/src/app/page.tsx new file mode 100644 index 00000000..af3f571b --- /dev/null +++ b/webapp/src/app/page.tsx @@ -0,0 +1,57 @@ +import { Cache } from '@/Cache'; +import HomeDashboard from '@/components/HomeDashboard'; +import MessageAdapterFactory from '@/utils/adapters/MessageAdapterFactory'; +import { ProjectCardProps } from '@/components/ProjectCard'; +import { RepoGit } from '@/RepoGit'; +import { ServerConfig } from '@/utils/serverConfig'; + +// Force dynamic rendering for this page. By default Next.js attempts to render +// this page statically. That means that it tries to render the page at build +// time instead of at runtime. That doesn't work: this page needs to fetch +// project-specific config files and perform git operations. So this little +// one-liner forces it into dynamic rendering mode. +// +// More info on dynamic vs static rendering at: +// https://nextjs.org/learn/dashboard-app/static-and-dynamic-rendering +// +// More info on `export const dynamic` at: +// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config +export const dynamic = 'force-dynamic'; + +export default async function Home() { + const serverConfig = await ServerConfig.read(); + const projects = await Promise.all( + serverConfig.projects.map<Promise<ProjectCardProps>>(async (project) => { + await RepoGit.cloneIfNotExist(project); + const repoGit = await RepoGit.getRepoGit(project); + const lyraConfig = await repoGit.getLyraConfig(); + const projectConfig = lyraConfig.getProjectConfigByPath( + project.projectPath, + ); + const msgAdapter = MessageAdapterFactory.createAdapter(projectConfig); + const messages = await msgAdapter.getMessages(); + const store = await Cache.getProjectStore(projectConfig); + const languages = await Promise.all( + projectConfig.languages.map(async (lang) => { + const translations = await store.getTranslations(lang); + return { + href: `/projects/${project.name}/${lang}`, + language: lang, + progress: translations + ? (Object.keys(translations).length / messages.length) * 100 + : 0, + }; + }), + ); + + return { + href: `/projects/${project.name}`, + languages, + messageCount: messages.length, + name: project.name, + }; + }), + ); + + return <HomeDashboard projects={projects} />; +} diff --git a/webapp/src/components/HomeDashboard.tsx b/webapp/src/components/HomeDashboard.tsx new file mode 100644 index 00000000..f78311b2 --- /dev/null +++ b/webapp/src/components/HomeDashboard.tsx @@ -0,0 +1,46 @@ +import { FC } from 'react'; +import { Box, List, Typography } from '@mui/joy'; +import ProjectCard, { ProjectCardProps } from './ProjectCard'; + +type HomeDashboardProps = { + projects: ProjectCardProps[]; +}; + +const HomeDashboard: FC<HomeDashboardProps> = ({ projects }) => { + return ( + <Box + alignItems="center" + display="flex" + flexDirection="column" + justifyContent="center" + minHeight="97vh" + > + <Typography alignSelf="flex-start" color="primary" component="h1"> + Your Lyra Projects + </Typography> + <List + sx={{ + '@media (min-width: 600px)': { + alignContent: 'center', + columnGap: 2, + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 300px));', + justifyContent: 'center', + }, + alignItems: 'center', + display: 'flex', + flex: 1, + flexDirection: 'column', + rowGap: 2, + width: '100%', + }} + > + {projects.map((project, i) => ( + <ProjectCard key={i} {...project} /> + ))} + </List> + </Box> + ); +}; + +export default HomeDashboard; diff --git a/webapp/src/components/ProjectCard.tsx b/webapp/src/components/ProjectCard.tsx new file mode 100644 index 00000000..ff8e83ce --- /dev/null +++ b/webapp/src/components/ProjectCard.tsx @@ -0,0 +1,172 @@ +import { FC } from 'react'; +import { Box, LinearProgress, Link, Typography } from '@mui/joy'; + +export type ProjectCardProps = { + /** + * The URL of the project page. + */ + href: string; + + /** + * The project's languages and their translation progress. + */ + languages: { + /** + * The URL of the page containing the project's messages in this language. + */ + + href: string; + + /** + * The name of the language. + */ + language: string; + + /** + * The percentage of messages translated in this language. 0 means none, 100 + * means all of them. + */ + progress: number; + }[]; + + /** + * The number of messages in the project. + */ + messageCount: number; + + /** + * The name of the project. + */ + name: string; +}; + +/** + * Project cards are the primary navigation element on the home screen. They + * display information about the project, and clicking them takes the user to + * the project page. + * + * Displaying a collection of structured information like this in a clickable + * card introduces some accessibility challenges. The implementation here + * employs the [Inclusive Components "pseudo-content trick"](https://inclusive-components.design/cards/). + * + */ +const ProjectCard: FC<ProjectCardProps> = ({ + href, + languages, + name, + messageCount, +}) => { + return ( + <Box component="li" sx={{ listStyleType: 'none' }} width="100%"> + <Box + bgcolor="neutral.50" + border={1} + borderColor="transparent" + borderRadius={8} + display="flex" + flexDirection="column" + position="relative" + px={3} + py={2} + rowGap={1} + sx={{ + ':focus-within, :hover': { + outlineColor: 'focusVisible', + outlineStyle: 'solid', + outlineWidth: 1, + }, + }} + > + <Typography component="h2"> + <Link + href={href} + sx={{ + '::after': { + bottom: 0, + content: '""', + left: 0, + position: 'absolute', + right: 0, + top: 0, + width: '100%', + }, + ':hover, :focus': { + outline: 'none', + textDecoration: 'none', + }, + color: 'inherit', + position: 'inherit', + }} + > + {name} + </Link> + </Typography> + <Typography>{messageCount} messages</Typography> + <Box + columnGap={1} + component="ul" + display="grid" + margin={0} + padding={0} + rowGap={1} + sx={{ + gridTemplateColumns: 'repeat(auto-fit, minmax(30px, 120px));', + width: '100%', + }} + > + {languages.map(({ href, language, progress }) => ( + <Box + key={language} + bgcolor="primary.50" + borderRadius={4} + component="li" + position="relative" + sx={{ + ':focus-within, :hover': { + outlineColor: 'focusVisible', + outlineStyle: 'solid', + outlineWidth: 1, + }, + listStyleType: 'none', + }} + > + <Box display="flex" flexDirection="column" px={3} py={2}> + <Link + href={href} + sx={{ + '::after': { + bottom: 0, + content: '""', + left: 0, + position: 'absolute', + right: 0, + top: 0, + width: '100%', + }, + ':hover, :focus': { + outline: 'none', + textDecoration: 'none', + }, + position: 'inherit', + }} + > + {language} + </Link> + <LinearProgress + determinate + size="lg" + sx={{ backgroundColor: '#ffffff' }} + thickness={8} + value={Math.min(progress, 100)} + variant="outlined" + /> + </Box> + </Box> + ))} + </Box> + </Box> + </Box> + ); +}; + +export default ProjectCard;