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;