diff --git a/bun.lock b/bun.lock index 1cc4a83f..1998c206 100644 --- a/bun.lock +++ b/bun.lock @@ -430,7 +430,7 @@ "@types/body-parser": ["@types/body-parser@1.19.5", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg=="], - "@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="], + "@types/bun": ["@types/bun@1.2.9", "", { "dependencies": { "bun-types": "1.2.9" } }, "sha512-epShhLGQYc4Bv/aceHbmBhOz1XgUnuTZgcxjxk+WXwNyDXavv5QHD1QEFV0FwbTSQtNq6g4ZcV6y0vZakTjswg=="], "@types/caseless": ["@types/caseless@0.12.5", "", {}, "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg=="], @@ -536,7 +536,7 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - "bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="], + "bun-types": ["bun-types@1.2.9", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-dk/kOEfQbajENN/D6FyiSgOKEuUi9PWfqKQJEgwKrCMWbjS/S6tEXp178mWvWAcUSYm9ArDlWHZKO3T/4cLXiw=="], "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], diff --git a/common/lib/panic.ts b/common/lib/panic.ts index 0a7ae204..63afe954 100644 --- a/common/lib/panic.ts +++ b/common/lib/panic.ts @@ -1,6 +1,8 @@ // unexpected error export function panic(reason: string): never { - throw new Error(reason, { - cause: "panic", - }); + throw new Error(reason); + // TODO: 型エラーとなるため一時的にコメントアウト + // throw new Error(reason, { + // cause: "panic", + // }); } diff --git a/server/prisma/sql/recommend.sql b/server/prisma/sql/recommend.sql index ad2ad7a4..1720f38b 100644 --- a/server/prisma/sql/recommend.sql +++ b/server/prisma/sql/recommend.sql @@ -1,6 +1,33 @@ -- $1 = senderId SELECT - *, + recv.id, + recv.name, + recv.gender, + recv.grade, + recv.faculty, + recv.department, + recv.intro, + recv."guid", + recv."pictureUrl", + json_agg(DISTINCT jsonb_build_object( + 'id', c.id, + 'name', c.name, + 'teacher', c.teacher, + 'slots', ( + SELECT json_agg( + jsonb_build_object( + 'courseId', "Slot"."courseId", + 'day', "Slot"."day", + 'period', "Slot"."period" + ) + ) FROM "Slot" WHERE "Slot"."courseId" = c.id) + ) + ) AS "courses", + json_agg(DISTINCT jsonb_build_object( + 'id', s.id, + 'name', s.name, + 'group', s.group + )) AS "interestSubjects", -- course overlap (SELECT COUNT(1) FROM "Course" course WHERE EXISTS (SELECT 1 FROM "Enrollment" e WHERE e."courseId" = course.id AND e."userId" = recv.id) @@ -12,6 +39,13 @@ SELECT AND EXISTS (SELECT 1 FROM "Interest" i WHERE i."subjectId" = subj.id AND i."userId" = $1) ) AS overlap FROM "User" recv + +INNER JOIN "Enrollment" ON "Enrollment"."userId" = recv.id +INNER JOIN "Course" c on c.id = "Enrollment"."courseId" +INNER JOIN "Slot" ON "Slot"."courseId" = c.id +INNER JOIN "Interest" ON "Interest"."userId" = recv.id +INNER JOIN "InterestSubject" s ON s.id = "Interest"."subjectId" + WHERE recv.id <> $1 AND NOT EXISTS ( @@ -26,5 +60,20 @@ AND NOT EXISTS ( AND status = 'PENDING' ) +-- 授業の登録も興味分野の登録も 0 件のユーザは除外 +AND ( + EXISTS ( + SELECT 1 FROM "Enrollment" e + WHERE e."userId" = recv.id + ) + OR + EXISTS ( + SELECT 1 FROM "Interest" i + WHERE i."userId" = recv.id + ) +) + +GROUP BY recv.id + ORDER BY overlap DESC LIMIT $2 OFFSET $3; diff --git a/server/src/functions/engines/recommendation.ts b/server/src/functions/engines/recommendation.ts index 64222a0a..bf1f78f6 100644 --- a/server/src/functions/engines/recommendation.ts +++ b/server/src/functions/engines/recommendation.ts @@ -1,9 +1,11 @@ import { recommend } from "@prisma/client/sql"; -import type { UserID, UserWithCoursesAndSubjects } from "common/types"; +import type { + Course, + InterestSubject, + UserID, + UserWithCoursesAndSubjects, +} from "common/types"; import { prisma } from "../../database/client"; -import { getCoursesByUserId } from "../../database/courses"; -import * as interest from "../../database/interest"; -import { getUserByID } from "../../database/users"; export async function recommendedTo( user: UserID, @@ -15,23 +17,16 @@ export async function recommendedTo( count: number; }> > { - const result = await prisma.$queryRawTyped(recommend(user, limit, offset)); - return Promise.all( - result.map(async (res) => { - const { overlap: count, ...u } = res; - if (count === null) throw new Error("count is null: something is wrong"); - // TODO: user の情報はここで再度 DB に問い合わせるのではなく、 recommend の sql で取得 - const user = await getUserByID(u.id); - const courses = getCoursesByUserId(u.id); - const subjects = interest.of(u.id); - return { - count: Number(count), - u: { - ...user, - courses: await courses, - interestSubjects: await subjects, - }, - }; - }), - ); + const users = await prisma.$queryRawTyped(recommend(user, limit, offset)); + return users.map((user) => { + const { overlap: count, ...u } = user; + return { + count: Number(count), + u: { + ...u, + interestSubjects: u.interestSubjects as InterestSubject[], // TODO: type + courses: u.courses as Course[], // TODO: type + }, + }; + }); } diff --git a/web/app/chat/layout.tsx b/web/app/chat/layout.tsx index fabe653b..31bd4e2b 100644 --- a/web/app/chat/layout.tsx +++ b/web/app/chat/layout.tsx @@ -9,7 +9,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
-
{children}
+
+ {children} +
diff --git a/web/app/friends/layout.tsx b/web/app/friends/layout.tsx index 0146483c..721d92db 100644 --- a/web/app/friends/layout.tsx +++ b/web/app/friends/layout.tsx @@ -9,7 +9,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
-
{children}
+
+ {children} +
diff --git a/web/app/globals.css b/web/app/globals.css index fe4c7bb8..d0c12603 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -11,3 +11,8 @@ .cm-li-btn { @apply no-animation h-auto w-full justify-start rounded-none border-none bg-white px-6 py-4 text-left font-normal text-base shadow-none hover:bg-zinc-100 focus:bg-zinc-300; } + +/* Bottom Bar の分の幅 */ +.cm-pb-footer { + padding-bottom: calc(3rem + env(safe-area-inset-bottom)); +} diff --git a/web/app/home/layout.tsx b/web/app/home/layout.tsx index ec50a110..cfb28d1f 100644 --- a/web/app/home/layout.tsx +++ b/web/app/home/layout.tsx @@ -11,7 +11,7 @@ export default function Layout({ <>
-
{children}
+
{children}
diff --git a/web/app/layout.tsx b/web/app/layout.tsx index f6ceb29d..3cfd693b 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,62 +1,30 @@ -"use client"; - -import { CssBaseline, ThemeProvider, createTheme } from "@mui/material"; -import { SnackbarProvider } from "notistack"; -import React from "react"; +import type React from "react"; import "./globals.css"; import "@fontsource/roboto/300.css"; import "@fontsource/roboto/400.css"; import "@fontsource/roboto/500.css"; import "@fontsource/roboto/700.css"; -import BanLandscape from "~/components/BanLandscape"; -import SSEProvider from "~/components/SSEProvider"; -import { AlertProvider } from "~/components/common/alert/AlertProvider"; +import type { Metadata, Viewport } from "next"; +import Providers from "~/components/Providers"; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", +}; -const theme = createTheme({ - palette: { - primary: { - main: "#039BE5", - }, - secondary: { - main: "#E9F8FF", - }, - }, -}); +export const metadata: Metadata = { + title: "CourseMate (β版)", + description: "同じ授業の人と友達になろう", +}; export default function RootLayout({ children, -}: { - children: React.ReactNode; -}) { +}: { children: React.ReactNode }) { return ( - - - - - CourseMate - - - - - - - - {/* */} - - {children} - {/* */} - - - - + {children} ); diff --git a/web/app/manifest.ts b/web/app/manifest.ts new file mode 100644 index 00000000..84e0b73f --- /dev/null +++ b/web/app/manifest.ts @@ -0,0 +1,25 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "CourseMate", + short_name: "CourseMate", + description: "同じ授業を履修している友達を見つけられるアプリ", + start_url: "/", + display: "standalone", + background_color: "#ffffff", + theme_color: "#ffffff", + icons: [ + { + src: "/icon-192x192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "/icon-512x512.png", + sizes: "512x512", + type: "image/png", + }, + ], + }; +} diff --git a/web/app/search/layout.tsx b/web/app/search/layout.tsx index 87bbc6a3..8f275579 100644 --- a/web/app/search/layout.tsx +++ b/web/app/search/layout.tsx @@ -9,7 +9,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
-
{children}
+
+ {children} +
diff --git a/web/app/settings/layout.tsx b/web/app/settings/layout.tsx index 0dfbe9fc..f3d9fa7a 100644 --- a/web/app/settings/layout.tsx +++ b/web/app/settings/layout.tsx @@ -11,7 +11,9 @@ export default function Layout({ <>
-
{children}
+
+ {children} +
diff --git a/web/components/BottomBar.tsx b/web/components/BottomBar.tsx index b644eb15..e1694695 100644 --- a/web/components/BottomBar.tsx +++ b/web/components/BottomBar.tsx @@ -36,7 +36,13 @@ function BottomBarCell({ export default function BottomBar(props: Props) { const { activeTab } = props; return ( -
+
{title && ( @@ -20,7 +20,6 @@ export default function Header(props: Props) { )} - {title ? (

{title} @@ -28,15 +27,33 @@ export default function Header(props: Props) { ) : ( )} - {info && ( - +
+ +
+

CourseMate は現在ベータ版です。

+ +
+

); } diff --git a/web/components/Providers.tsx b/web/components/Providers.tsx new file mode 100644 index 00000000..250280fa --- /dev/null +++ b/web/components/Providers.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { CssBaseline, ThemeProvider, createTheme } from "@mui/material"; +import { SnackbarProvider } from "notistack"; +import React from "react"; +import type { ReactNode } from "react"; +import BanLandscape from "~/components/BanLandscape"; +import SSEProvider from "~/components/SSEProvider"; +import { AlertProvider } from "~/components/common/alert/AlertProvider"; + +const theme = createTheme({ + palette: { + primary: { + main: "#039BE5", + }, + secondary: { + main: "#E9F8FF", + }, + }, +}); + +export default function Providers({ children }: { children: ReactNode }) { + return ( + + + + + + + {/* */} + + {children} + {/* */} + + + + + ); +} diff --git a/web/public/icon-192x192.png b/web/public/icon-192x192.png new file mode 100644 index 00000000..821e0465 Binary files /dev/null and b/web/public/icon-192x192.png differ diff --git a/web/public/icon-512x512.png b/web/public/icon-512x512.png new file mode 100644 index 00000000..518d10a9 Binary files /dev/null and b/web/public/icon-512x512.png differ