feat(user): add UserDetailsView to list (current)user (private-and-)public repositories
+ 219
- 55
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1663791331427,
+  "_generatedAtUnix": 1663803734312,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "islands": {

...
@@ -43,10 +43,6 @@
       "hash": "389861a6e7f9ff12026ddf5e91bdf4f06646116e",
       "pathSource": "./app/views/InternalErrorView.tsx"
     },
-    "DashboardView": {
-      "hash": "316530648e98734fd1e983c8dcf9062d772363fb",
-      "pathSource": "./app/views/auth/DashboardView.tsx"
-    },
     "LoginView": {
       "hash": "6d69b3db6e0c92ebcc9d7611a0275e06dcd18379",
       "pathSource": "./app/views/auth/LoginView.tsx"

...
@@ -78,6 +74,14 @@
     "RepositoryExploreView": {
       "hash": "416d8fe270478274dc12f6ac09e75bb789842ec3",
       "pathSource": "./app/views/repository/RepositoryExploreView.tsx"
+    },
+    "UserDashboardView": {
+      "hash": "142f85b0c41050a27b52a6356b9739c6f67824b3",
+      "pathSource": "./app/views/user/UserDashboardView.tsx"
+    },
+    "UserDetailsView": {
+      "hash": "af2b33f9d15192719e8d32339d82d0e893660a69",
+      "pathSource": "./app/views/user/UserDetailsView.tsx"
     }
   }
 }

app/components/PageHeader.tsx
@@ -20,6 +20,9 @@ export const PageHeader: VFC<PageHeaderProps & WithThemeSchemeProp> = ({
     if (commonProps.authenticated) {
       return (
         <>
+          <a href={`/@${commonProps.currentUserUsername || "ghost"}`}>
+            {commonProps.currentUserUsername || "ghost"}
+          </a>
           <a aria-label={"Log off your account"} href={"/auth/logout"}>
             Logout
           </a>

app/controllers/auth/index.ts
@@ -1,4 +1,3 @@
-import { default as getDashboardView } from "./getDashboardView";
 import { default as getLoginView } from "./getLoginView";
 import { default as getLogoutAction } from "./getLogoutAction";
 import { default as getRegisterView } from "./getRegisterView";

...
@@ -6,7 +5,6 @@ import { default as postLoginAction } from "./postLoginAction";
 import { default as postRegisterAction } from "./postRegisterAction";
 
 export const AuthController = {
-  getDashboardView,
   getLoginView,
   getLogoutAction,
   getRegisterView,

app/controllers/auth/getDashboardView.ts -> app/controllers/user/getUserDashboardView.ts
@@ -4,26 +4,33 @@ import type { ReqHandler } from "@ethicdevs/react-monolith";
 import { AppRoute } from "../../routes";
 import { makeUsersService } from "../../services/user";
 // app views
-import DashboardView, {
-  DashboardViewProps,
-} from "../../views/auth/DashboardView";
+import UserDashboardView, {
+  UserDashboardViewProps,
+} from "../../views/user/UserDashboardView";
 
-const getDashboardView: ReqHandler = async (request, reply) => {
+const getUserDashboardView: ReqHandler = async (request, reply) => {
   const { authenticated, curr_user_uid } = request.session.data;
+
   if (authenticated === false || curr_user_uid == null) {
-    reply.redirect(307, request.namedViewsPathMap[AppRoute.AUTH_LOGIN]);
+    reply.redirect(302, request.namedViewsPathMap[AppRoute.AUTH_LOGIN]);
     return reply;
   }
 
   const usersService = makeUsersService({ request });
   const currentUser = await usersService.getUserById(curr_user_uid);
-  const repositories = await usersService.getUserRepositories(curr_user_uid);
+
+  if (currentUser == null) {
+    reply.redirect(302, request.namedViewsPathMap[AppRoute.AUTH_LOGIN]);
+    return reply;
+  }
+
+  const repositories = await usersService.getUserRepositories(currentUser);
 
   const reqHandler = reply.makeRequestHandler(request, reply);
-  return reqHandler<DashboardViewProps>(DashboardView.name, {
+  return reqHandler<UserDashboardViewProps>(UserDashboardView.name, {
     currentUser,
     repositories,
   });
 };
 
-export default getDashboardView;
+export default getUserDashboardView;

new file
app/controllers/user/getUserDetailsView.ts
@@ -0,0 +1,39 @@
+// 3rd-party
+import type { ReqHandler } from "@ethicdevs/react-monolith";
+import { User } from "@prisma/client";
+// app
+import { AppRoute, AppRoutesParams } from "../../routes";
+import { makeUsersService } from "../../services/user";
+// app views
+import UserDetailsView, {
+  UserDetailsViewProps,
+} from "../../views/user/UserDetailsView";
+
+const getUserDetailsView: ReqHandler = async (request, reply) => {
+  const { username } =
+    request.params as AppRoutesParams[AppRoute.USER_DETAILS]["params"];
+
+  const { curr_user_uid } = request.session.data;
+
+  const usersService = makeUsersService({ request });
+  const user = await usersService.getUserByUsername(username);
+
+  if (user == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  let currentUser: User | null = null;
+
+  if (curr_user_uid != null && user.id === curr_user_uid) {
+    currentUser = await usersService.getUserById(curr_user_uid);
+  }
+
+  const reqHandler = reply.makeRequestHandler(request, reply);
+  return reqHandler<UserDetailsViewProps>(UserDetailsView.name, {
+    currentUser,
+    repositories: await usersService.getUserRepositories(user),
+    user,
+  });
+};
+
+export default getUserDetailsView;

new file
app/controllers/user/index.ts
@@ -0,0 +1,7 @@
+import { default as getUserDashboardView } from "./getUserDashboardView";
+import { default as getUserDetailsView } from "./getUserDetailsView";
+
+export const UserController = {
+  getUserDashboardView,
+  getUserDetailsView,
+};

@@ -12,6 +12,7 @@ import { authenticatedOrLogin, guestOrRedirect } from "./utils/server";
 import { AuthController } from "./controllers/auth";
 import { OrganizationController } from "./controllers/organization";
 import { RepositoryController } from "./controllers/repository";
+import { UserController } from "./controllers/user";
 import * as HomeController from "./controllers/HomeController";
 import * as ThemeController from "./controllers/ThemeController";
 

...
@@ -24,6 +25,7 @@ export enum AppRoute {
   AUTH_LOGIN_ACTION = "auth.login.action",
   AUTH_LOGOUT_ACTION = "auth.logout.action",
   USER_DASHBOARD = "user.dashboard",
+  USER_DETAILS = "user.details",
   ORGANIZATION_DETAILS = "organization.details",
   REPOSITORY_EXPLORE = "repository.explore",
   REPOSITORY_COMMITS_LOG = "repository.commits_log",

...
@@ -55,6 +57,11 @@ export interface AppRoutesParams extends IRouteParams {
   };
   [AppRoute.AUTH_LOGOUT_ACTION]: undefined;
   [AppRoute.USER_DASHBOARD]: undefined;
+  [AppRoute.USER_DETAILS]: {
+    params: {
+      username: string;
+    };
+  };
   [AppRoute.ORGANIZATION_DETAILS]: {
     params: {
       orgSlug: string;

...
@@ -141,6 +148,18 @@ export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {
   },
   [AppRoute.AUTH_LOGOUT_ACTION]: undefined,
   [AppRoute.USER_DASHBOARD]: undefined,
+  [AppRoute.USER_DETAILS]: {
+    params: {
+      type: "object",
+      required: ["username"],
+      additionalProperties: false,
+      properties: {
+        username: {
+          type: "string",
+        },
+      },
+    },
+  },
   [AppRoute.ORGANIZATION_DETAILS]: {
     params: {
       type: "object",

...
@@ -340,7 +359,13 @@ const RootAppRouter: AppRouter = () => {
           method={"GET"}
           path={"/dashboard"}
           preHandler={loggedOrLoginRedirect}
-          handler={AuthController.getDashboardView}
+          handler={UserController.getUserDashboardView}
+        />
+        <Router.Route
+          name={AppRoute.USER_DETAILS}
+          method={"GET"}
+          path={"/@:username"}
+          handler={UserController.getUserDetailsView}
         />
         {/* --- */}
         <Router.Route

app/services/repository/getRepositoryCommitLog.ts
@@ -51,27 +51,33 @@ const makeGetRepositoryCommitLog: ServiceMethodFactory<
       cwd: repoPath,
     });
 
-    const gitLogResult = await new Promise<RepositoryLog[]>(
-      (resolve, reject) => {
-        let buffer = [] as string[];
-        gitLogProcess.stdout.on("data", (data) => buffer.push(data));
-        gitLogProcess.stderr.on("data", (data) => {
-          reject(new Error(Buffer.from(data).toString("utf-8")));
-        });
-        gitLogProcess.stdout.on("close", () => {
-          const escapedJson = buffer
-            .join("")
-            .replace(/\n\^@\^/g, "\\n^@^") // Escape unterminated lines: \n^@\^ -> \\n^@\^
-            .replace(/"/gm, '\\"') // Escape double-quotes: " -> \"
-            .replace(/\^@\^/gm, '"'); // Escaped double-quotes back to double quotes
-          resolve(
-            JSON.parse(`[${escapedJson.substring(0, escapedJson.length - 1)}]`)
-          );
-        });
-      }
-    );
+    try {
+      const gitLogResult = await new Promise<RepositoryLog[]>(
+        (resolve, reject) => {
+          let buffer = [] as string[];
+          gitLogProcess.stdout.on("data", (data) => buffer.push(data));
+          gitLogProcess.stderr.on("data", (data) => {
+            reject(new Error(Buffer.from(data).toString("utf-8")));
+          });
+          gitLogProcess.stdout.on("close", () => {
+            const escapedJson = buffer
+              .join("")
+              .replace(/\n\^@\^/g, "\\n^@^") // Escape unterminated lines: \n^@\^ -> \\n^@\^
+              .replace(/"/gm, '\\"') // Escape double-quotes: " -> \"
+              .replace(/\^@\^/gm, '"'); // Escaped double-quotes back to double quotes
+            resolve(
+              JSON.parse(
+                `[${escapedJson.substring(0, escapedJson.length - 1)}]`
+              )
+            );
+          });
+        }
+      );
 
-    return gitLogResult as RepositoryLog[];
+      return gitLogResult as RepositoryLog[];
+    } catch (err) {
+      return [];
+    }
   };
 };
 

app/services/user/getUserRepositories.ts
@@ -1,32 +1,32 @@
 // 1st-party
 import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
 // generated via script[generate:prisma]
-import { Organization, Repository } from "@prisma/client";
+import { Prisma, Organization, Repository, User } from "@prisma/client";
 // app
 import type { UsersServiceDeps } from "./types";
 
 const getUserRepositories: ServiceMethodFactory<
   UsersServiceDeps,
-  [string],
+  [User, undefined | Prisma.RepositoryWhereInput],
   Promise<(Repository & { parentOrg: Organization })[]>
 > = ({ request }) => {
-  return async (userId) => {
+  return async (user, where = undefined) => {
     const userRepos = await request.prisma.repository.findMany({
       include: {
         organization: true,
       },
-      where: {
+      where: where || {
         OR: [
           {
             organization: {
-              ownerId: userId,
+              ownerId: user.id,
             },
           },
           {
             organization: {
               memberships: {
                 some: {
-                  userId,
+                  userId: user.id,
                 },
               },
             },

app/services/user/types.ts
@@ -4,6 +4,7 @@ import { ServiceApiContract } from "@ethicdevs/react-monolith";
 import { FastifyRequest } from "fastify";
 // generated via script[generate:prisma]
 import {
+  Prisma,
   Organization,
   OrganizationMembership,
   Repository,

...
@@ -19,7 +20,8 @@ export interface UsersServiceAPI extends ServiceApiContract {
   ): Promise<OrganizationMembership[]>;
   getUserOrganizations(userId: string): Promise<Organization[]>;
   getUserRepositories(
-    userId: string
+    user: User,
+    where?: Prisma.RepositoryWhereInput
   ): Promise<(Repository & { parentOrg: Organization })[]>;
 }
 

@@ -1,4 +1,4 @@
-import type { Prisma, GlobalRole } from "@prisma/client";
+import type { Prisma, GlobalRole, User } from "@prisma/client";
 
 export type AppThemeScheme = "light" | "dark";
 export type WithThemeSchemeProp = {

...
@@ -20,6 +20,11 @@ export interface AppSessionData extends Prisma.JsonObject {
 
 export interface CommonViewProps {
   authenticated: boolean;
+  currentUserAvatarUri: string | null;
+  currentUserId: string | null;
+  currentUserRole: GlobalRole | null;
+  currentUserUsername: string | null;
+  flashMessage: string | null;
   themeScheme: AppThemeScheme;
   title?: string;
 }

app/utils/server/makeRequestHandler.ts
@@ -13,16 +13,38 @@ export const makeRequestHandler = {
         props?: T & CommonViewProps,
         viewCtx?: ViewContext
       ) => {
+        const {
+          authenticated,
+          curr_user_uid,
+          curr_user_avatar_uri,
+          curr_user_role,
+          curr_user_username,
+          flash_message,
+        } = request.session.data;
+
+        const title = props != null ? props.title : "";
+
+        const themeSchemeFromCookies =
+          "theme_scheme" in request.cookies
+            ? request.cookies["theme_scheme"].split(".")[0]
+            : null;
+
+        const themeScheme =
+          (themeSchemeFromCookies || Const.DEFAULT_THEME_SCHEME) === "light"
+            ? "light"
+            : "dark";
+
         const viewProps: T & { commonProps: CommonViewProps } = {
           ...props,
           commonProps: {
-            authenticated: request.session.data.authenticated,
-            title: props?.title,
-            themeScheme:
-              (request.cookies?.["theme_scheme"]?.split(".")?.[0] ||
-                Const.DEFAULT_THEME_SCHEME) === "light"
-                ? "light"
-                : "dark",
+            authenticated,
+            currentUserAvatarUri: curr_user_avatar_uri,
+            currentUserId: curr_user_uid,
+            currentUserRole: curr_user_role,
+            currentUserUsername: curr_user_username,
+            flashMessage: flash_message,
+            title,
+            themeScheme,
           },
         } as T & { commonProps: CommonViewProps };
 

app/views/auth/DashboardView.tsx -> app/views/user/UserDashboardView.tsx
@@ -10,12 +10,12 @@ import { Layout, PageWrapper } from "../../components";
 // app islands
 import RepositoriesList from "../../islands/RepositoriesList";
 
-export interface DashboardViewProps extends CommonProps {
+export interface UserDashboardViewProps extends CommonProps {
   currentUser: User;
   repositories: (Repository & { parentOrg: Organization })[];
 }
 
-const DashboardView: ReactView<DashboardViewProps> = ({
+const UserDashboardView: ReactView<UserDashboardViewProps> = ({
   commonProps,
   currentUser,
   repositories,

...
@@ -36,5 +36,5 @@ const DashboardView: ReactView<DashboardViewProps> = ({
   );
 };
 
-DashboardView.displayName = "DashboardView";
-export default DashboardView;
+UserDashboardView.displayName = "UserDashboardView";
+export default UserDashboardView;

new file
app/views/user/UserDetailsView.tsx
@@ -0,0 +1,46 @@
+// 1st-party
+import type { ReactView } from "@ethicdevs/react-monolith";
+// 3rd-party
+import React from "react";
+// generated via script[generate:prisma]
+import type { Organization, Repository, User } from "@prisma/client";
+// app
+import type { CommonProps } from "../../types";
+import { Layout, PageWrapper } from "../../components";
+// app islands
+import RepositoriesList from "../../islands/RepositoriesList";
+
+export interface UserDetailsViewProps extends CommonProps {
+  user: User;
+  currentUser: User | null;
+  repositories: (Repository & { parentOrg: Organization })[];
+}
+
+const UserDetailsView: ReactView<UserDetailsViewProps> = ({
+  commonProps,
+  currentUser,
+  user,
+  repositories,
+}) => {
+  return (
+    <Layout {...commonProps}>
+      <PageWrapper>
+        <h1>{user.displayName || user.username}</h1>
+        <h2>
+          {currentUser != null && currentUser.id === user.id
+            ? "Your repositories"
+            : "Public repositories from this user"}
+        </h2>
+        <div
+          data-islandid={`${RepositoriesList.name}$$0`}
+          style={{ width: "100%" }}
+        >
+          <RepositoriesList repositories={repositories} />
+        </div>
+      </PageWrapper>
+    </Layout>
+  );
+};
+
+UserDetailsView.displayName = "UserDetailsView";
+export default UserDetailsView;