diff --git a/application/client/babel.config.js b/application/client/babel.config.js index c3c574591a..de8c6128e5 100644 --- a/application/client/babel.config.js +++ b/application/client/babel.config.js @@ -4,16 +4,15 @@ module.exports = { [ "@babel/preset-env", { - targets: "ie 11", - corejs: "3", - modules: "commonjs", + targets: "chrome >= 120", + modules: false, useBuiltIns: false, }, ], [ "@babel/preset-react", { - development: true, + development: false, runtime: "automatic", }, ], diff --git a/application/client/package.json b/application/client/package.json index 9f8e80a6a8..a899353261 100644 --- a/application/client/package.json +++ b/application/client/package.json @@ -5,7 +5,7 @@ "license": "MPL-2.0", "author": "CyberAgent, Inc.", "scripts": { - "build": "NODE_ENV=development webpack", + "build": "NODE_ENV=production webpack", "typecheck": "tsc" }, "dependencies": { @@ -57,6 +57,7 @@ "@babel/preset-env": "7.28.3", "@babel/preset-react": "7.27.1", "@babel/preset-typescript": "7.27.1", + "@tailwindcss/postcss": "4.2.2", "@tsconfig/strictest": "2.0.8", "@types/bluebird": "3.5.42", "@types/common-tags": "1.8.4", @@ -83,6 +84,7 @@ "postcss-loader": "8.2.0", "postcss-preset-env": "10.4.0", "react-markdown": "10.1.0", + "tailwindcss": "4.2.2", "typescript": "5.9.3", "webpack": "5.102.1", "webpack-cli": "6.0.1", diff --git a/application/client/postcss.config.js b/application/client/postcss.config.js index d7ee920b94..1517f884ef 100644 --- a/application/client/postcss.config.js +++ b/application/client/postcss.config.js @@ -1,11 +1,7 @@ -const postcssImport = require("postcss-import"); -const postcssPresetEnv = require("postcss-preset-env"); +const tailwindcss = require("@tailwindcss/postcss"); module.exports = { plugins: [ - postcssImport(), - postcssPresetEnv({ - stage: 3, - }), + tailwindcss(), ], }; diff --git a/application/client/src/components/direct_message/DirectMessageListPage.tsx b/application/client/src/components/direct_message/DirectMessageListPage.tsx index 5a373e918e..b315ce2f5c 100644 --- a/application/client/src/components/direct_message/DirectMessageListPage.tsx +++ b/application/client/src/components/direct_message/DirectMessageListPage.tsx @@ -1,4 +1,3 @@ -import moment from "moment"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button"; @@ -8,6 +7,8 @@ import { useWs } from "@web-speed-hackathon-2026/client/src/hooks/use_ws"; import { fetchJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; +const rtf = new Intl.RelativeTimeFormat("ja", { numeric: "auto" }); + interface Props { activeUser: Models.User; newDmModalId: string; @@ -86,8 +87,10 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
{peer.profileImage.alt}
@@ -100,7 +103,17 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => { className="text-cax-text-subtle text-xs" dateTime={lastMessage.createdAt} > - {moment(lastMessage.createdAt).locale("ja").fromNow()} + {(() => { + const diff = Date.now() - new Date(lastMessage.createdAt).getTime(); + const sec = Math.round(diff / 1000); + if (sec < 60) return rtf.format(-sec, "second"); + const min = Math.round(diff / 60000); + if (min < 60) return rtf.format(-min, "minute"); + const hr = Math.round(diff / 3600000); + if (hr < 24) return rtf.format(-hr, "hour"); + const day = Math.round(diff / 86400000); + return rtf.format(-day, "day"); + })()} )}
diff --git a/application/client/src/components/direct_message/DirectMessagePage.tsx b/application/client/src/components/direct_message/DirectMessagePage.tsx index 098c7d2894..b54d9467f0 100644 --- a/application/client/src/components/direct_message/DirectMessagePage.tsx +++ b/application/client/src/components/direct_message/DirectMessagePage.tsx @@ -1,5 +1,4 @@ import classNames from "classnames"; -import moment from "moment"; import { ChangeEvent, useCallback, @@ -15,6 +14,8 @@ import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components import { DirectMessageFormData } from "@web-speed-hackathon-2026/client/src/direct_message/types"; import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path"; +const timeFormatter = new Intl.DateTimeFormat("ja", { hour: "2-digit", minute: "2-digit", hour12: false }); + interface Props { conversationError: Error | null; conversation: Models.DirectMessageConversation; @@ -74,15 +75,17 @@ export const DirectMessagePage = ({ ); useEffect(() => { - const id = setInterval(() => { - const height = Number(window.getComputedStyle(document.body).height.replace("px", "")); + const scrollToBottom = () => { + const height = document.body.scrollHeight; if (height !== scrollHeightRef.current) { scrollHeightRef.current = height; window.scrollTo(0, height); } - }, 1); - - return () => clearInterval(id); + }; + scrollToBottom(); + const observer = new MutationObserver(scrollToBottom); + observer.observe(document.body, { childList: true, subtree: true }); + return () => observer.disconnect(); }, []); if (conversationError != null) { @@ -100,6 +103,8 @@ export const DirectMessagePage = ({ alt={peer.profileImage.alt} className="h-12 w-12 rounded-full object-cover" src={getProfileImagePath(peer.profileImage.id)} + width={48} + height={48} />

@@ -141,7 +146,7 @@ export const DirectMessagePage = ({

{isActiveUserSend && message.isRead && ( 既読 diff --git a/application/client/src/components/foundation/AspectRatioBox.tsx b/application/client/src/components/foundation/AspectRatioBox.tsx index 0ae891963c..aa1c54cbf3 100644 --- a/application/client/src/components/foundation/AspectRatioBox.tsx +++ b/application/client/src/components/foundation/AspectRatioBox.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useRef, useState } from "react"; +import { ReactNode } from "react"; interface Props { aspectHeight: number; @@ -10,28 +10,9 @@ interface Props { * 親要素の横幅を基準にして、指定したアスペクト比のブロック要素を作ります */ export const AspectRatioBox = ({ aspectHeight, aspectWidth, children }: Props) => { - const ref = useRef(null); - const [clientHeight, setClientHeight] = useState(0); - - useEffect(() => { - // clientWidth とアスペクト比から clientHeight を計算する - function calcStyle() { - const clientWidth = ref.current?.clientWidth ?? 0; - setClientHeight((clientWidth / aspectWidth) * aspectHeight); - } - setTimeout(() => calcStyle(), 500); - - // ウィンドウサイズが変わるたびに計算する - window.addEventListener("resize", calcStyle, { passive: false }); - return () => { - window.removeEventListener("resize", calcStyle); - }; - }, [aspectHeight, aspectWidth]); - return ( -
- {/* 高さが計算できるまで render しない */} - {clientHeight !== 0 ?
{children}
: null} +
+
{children}
); }; diff --git a/application/client/src/components/foundation/CoveredImage.tsx b/application/client/src/components/foundation/CoveredImage.tsx index 8ad9cc1f7d..a0dcf425b3 100644 --- a/application/client/src/components/foundation/CoveredImage.tsx +++ b/application/client/src/components/foundation/CoveredImage.tsx @@ -1,70 +1,60 @@ -import classNames from "classnames"; -import sizeOf from "image-size"; -import { load, ImageIFD } from "piexifjs"; -import { MouseEvent, RefCallback, useCallback, useId, useMemo, useState } from "react"; +import { MouseEvent, useCallback, useId, useState } from "react"; import { Button } from "@web-speed-hackathon-2026/client/src/components/foundation/Button"; import { Modal } from "@web-speed-hackathon-2026/client/src/components/modal/Modal"; -import { useFetch } from "@web-speed-hackathon-2026/client/src/hooks/use_fetch"; import { fetchBinary } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; interface Props { src: string; + priority?: boolean; } /** * アスペクト比を維持したまま、要素のコンテンツボックス全体を埋めるように画像を拡大縮小します */ -export const CoveredImage = ({ src }: Props) => { +export const CoveredImage = ({ src, priority }: Props) => { const dialogId = useId(); // ダイアログの背景をクリックしたときに投稿詳細ページに遷移しないようにする const handleDialogClick = useCallback((ev: MouseEvent) => { ev.stopPropagation(); }, []); - const { data, isLoading } = useFetch(src, fetchBinary); + const [alt, setAlt] = useState(""); + const [altLoaded, setAltLoaded] = useState(false); - const imageSize = useMemo(() => { - return data != null ? sizeOf(Buffer.from(data)) : { height: 0, width: 0 }; - }, [data]); - - const alt = useMemo(() => { - const exif = data != null ? load(Buffer.from(data).toString("binary")) : null; - const raw = exif?.["0th"]?.[ImageIFD.ImageDescription]; - return raw != null ? new TextDecoder().decode(Buffer.from(raw, "binary")) : ""; - }, [data]); - - const blobUrl = useMemo(() => { - return data != null ? URL.createObjectURL(new Blob([data])) : null; - }, [data]); - - const [containerSize, setContainerSize] = useState({ height: 0, width: 0 }); - const callbackRef = useCallback>((el) => { - setContainerSize({ - height: el?.clientHeight ?? 0, - width: el?.clientWidth ?? 0, - }); - }, []); - - if (isLoading || data === null || blobUrl === null) { - return null; - } - - const containerRatio = containerSize.height / containerSize.width; - const imageRatio = imageSize?.height / imageSize?.width; + // ALT テキストを EXIF からオンデマンドで取得(ボタンクリック時のみ) + const loadAlt = useCallback(async () => { + if (altLoaded) return; + setAltLoaded(true); + try { + const data = await fetchBinary(src); + const { load, ImageIFD } = await import("piexifjs"); + const arr = new Uint8Array(data); + let binary = ""; + for (let i = 0; i < arr.length; i++) { + binary += String.fromCharCode(arr[i]!); + } + const exif = load(binary); + const raw = exif?.["0th"]?.[ImageIFD.ImageDescription]; + if (raw != null) { + const decoded = new TextDecoder().decode( + Uint8Array.from(raw as string, (c: string) => c.charCodeAt(0)), + ); + setAlt(decoded); + } + } catch { + // EXIF 読み取り失敗は無視 + } + }, [src, altLoaded]); return ( -
+
{alt} imageRatio, - "w-full h-auto": containerRatio <= imageRatio, - }, - )} - src={blobUrl} + className="h-full w-full object-cover" + src={src} + loading={priority ? undefined : "lazy"} + {...(priority ? { fetchPriority: "high" as const } : {})} /> diff --git a/application/client/src/components/foundation/InfiniteScroll.tsx b/application/client/src/components/foundation/InfiniteScroll.tsx index 408f24c107..0221fde640 100644 --- a/application/client/src/components/foundation/InfiniteScroll.tsx +++ b/application/client/src/components/foundation/InfiniteScroll.tsx @@ -13,14 +13,9 @@ export const InfiniteScroll = ({ children, fetchMore, items }: Props) => { useEffect(() => { const handler = () => { - // 念の為 2の18乗 回、最下部かどうかを確認する - const hasReached = Array.from(Array(2 ** 18), () => { - return window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight; - }).every(Boolean); + const hasReached = window.innerHeight + Math.ceil(window.scrollY) >= document.body.offsetHeight; - // 画面最下部にスクロールしたタイミングで、登録したハンドラを呼び出す if (hasReached && !prevReachedRef.current) { - // アイテムがないときは追加で読み込まない if (latestItem !== undefined) { fetchMore(); } @@ -29,19 +24,14 @@ export const InfiniteScroll = ({ children, fetchMore, items }: Props) => { prevReachedRef.current = hasReached; }; - // 最初は実行されないので手動で呼び出す prevReachedRef.current = false; handler(); - document.addEventListener("wheel", handler, { passive: false }); - document.addEventListener("touchmove", handler, { passive: false }); - document.addEventListener("resize", handler, { passive: false }); - document.addEventListener("scroll", handler, { passive: false }); + document.addEventListener("scroll", handler, { passive: true }); + window.addEventListener("resize", handler, { passive: true }); return () => { - document.removeEventListener("wheel", handler); - document.removeEventListener("touchmove", handler); - document.removeEventListener("resize", handler); document.removeEventListener("scroll", handler); + window.removeEventListener("resize", handler); }; }, [latestItem, fetchMore]); diff --git a/application/client/src/components/foundation/PausableMovie.tsx b/application/client/src/components/foundation/PausableMovie.tsx index 98b0df55b0..bcb6d57d7e 100644 --- a/application/client/src/components/foundation/PausableMovie.tsx +++ b/application/client/src/components/foundation/PausableMovie.tsx @@ -1,12 +1,8 @@ import classNames from "classnames"; -import { Animator, Decoder } from "gifler"; -import { GifReader } from "omggif"; -import { RefCallback, useCallback, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { AspectRatioBox } from "@web-speed-hackathon-2026/client/src/components/foundation/AspectRatioBox"; import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon"; -import { useFetch } from "@web-speed-hackathon-2026/client/src/hooks/use_fetch"; -import { fetchBinary } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; interface Props { src: string; @@ -14,57 +10,32 @@ interface Props { /** * クリックすると再生・一時停止を切り替えます。 + * ブラウザネイティブの GIF 表示を利用(JS デコード不要で TBT 改善) */ export const PausableMovie = ({ src }: Props) => { - const { data, isLoading } = useFetch(src, fetchBinary); - - const animatorRef = useRef(null); - const canvasCallbackRef = useCallback>( - (el) => { - animatorRef.current?.stop(); - - if (el === null || data === null) { - return; - } - - // GIF を解析する - const reader = new GifReader(new Uint8Array(data)); - const frames = Decoder.decodeFramesSync(reader); - const animator = new Animator(reader, frames); - - animator.animateInCanvas(el); - animator.onFrame(frames[0]!); - - // 視覚効果 off のとき GIF を自動再生しない - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - setIsPlaying(false); - animator.stop(); - } else { - setIsPlaying(true); - animator.start(); - } - - animatorRef.current = animator; - }, - [data], - ); - + const imgRef = useRef(null); + const canvasRef = useRef(null); const [isPlaying, setIsPlaying] = useState(true); + const handleClick = useCallback(() => { - setIsPlaying((isPlaying) => { - if (isPlaying) { - animatorRef.current?.stop(); - } else { - animatorRef.current?.start(); + setIsPlaying((prev) => { + if (prev) { + // 一時停止: 現在のフレームをキャンバスにキャプチャして表示 + const img = imgRef.current; + const canvas = canvasRef.current; + if (img && canvas) { + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.drawImage(img, 0, 0); + } + } } - return !isPlaying; + return !prev; }); }, []); - if (isLoading || data === null) { - return null; - } - return (
diff --git a/application/client/src/components/post/ImageArea.tsx b/application/client/src/components/post/ImageArea.tsx index 27fe9c018c..15ed391c60 100644 --- a/application/client/src/components/post/ImageArea.tsx +++ b/application/client/src/components/post/ImageArea.tsx @@ -6,9 +6,10 @@ import { getImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_pat interface Props { images: Models.Image[]; + priority?: boolean; } -export const ImageArea = ({ images }: Props) => { +export const ImageArea = ({ images, priority }: Props) => { return (
@@ -24,7 +25,7 @@ export const ImageArea = ({ images }: Props) => { "row-span-2": images.length <= 2 || (images.length === 3 && idx === 0), })} > - +
); })} diff --git a/application/client/src/components/post/PostItem.tsx b/application/client/src/components/post/PostItem.tsx index 5fa904c91a..83d03bba6e 100644 --- a/application/client/src/components/post/PostItem.tsx +++ b/application/client/src/components/post/PostItem.tsx @@ -1,6 +1,6 @@ -import moment from "moment"; - import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link"; + +const dateFormatter = new Intl.DateTimeFormat("ja", { year: "numeric", month: "long", day: "numeric" }); import { ImageArea } from "@web-speed-hackathon-2026/client/src/components/post/ImageArea"; import { MovieArea } from "@web-speed-hackathon-2026/client/src/components/post/MovieArea"; import { SoundArea } from "@web-speed-hackathon-2026/client/src/components/post/SoundArea"; @@ -23,7 +23,10 @@ export const PostItem = ({ post }: Props) => { > {post.user.profileImage.alt}
@@ -52,7 +55,7 @@ export const PostItem = ({ post }: Props) => {
{post.images?.length > 0 ? (
- +
) : null} {post.movie ? ( @@ -67,8 +70,8 @@ export const PostItem = ({ post }: Props) => { ) : null}

-

diff --git a/application/client/src/components/post/TranslatableText.tsx b/application/client/src/components/post/TranslatableText.tsx index d772529d92..4ea0fa41eb 100644 --- a/application/client/src/components/post/TranslatableText.tsx +++ b/application/client/src/components/post/TranslatableText.tsx @@ -1,7 +1,5 @@ import { useCallback, useState } from "react"; -import { createTranslator } from "@web-speed-hackathon-2026/client/src/utils/create_translator"; - type State = | { type: "idle"; text: string } | { type: "loading" } @@ -20,6 +18,7 @@ export const TranslatableText = ({ text }: Props) => { (async () => { updateState({ type: "loading" }); try { + const { createTranslator } = await import("@web-speed-hackathon-2026/client/src/utils/create_translator"); using translator = await createTranslator({ sourceLanguage: "ja", targetLanguage: "en", diff --git a/application/client/src/components/timeline/Timeline.tsx b/application/client/src/components/timeline/Timeline.tsx index 752a4d973b..8540ca6034 100644 --- a/application/client/src/components/timeline/Timeline.tsx +++ b/application/client/src/components/timeline/Timeline.tsx @@ -7,8 +7,8 @@ interface Props { export const Timeline = ({ timeline }: Props) => { return (
- {timeline.map((post) => { - return ; + {timeline.map((post, index) => { + return ; })}
); diff --git a/application/client/src/components/timeline/TimelineItem.tsx b/application/client/src/components/timeline/TimelineItem.tsx index 21b88980f8..9487022515 100644 --- a/application/client/src/components/timeline/TimelineItem.tsx +++ b/application/client/src/components/timeline/TimelineItem.tsx @@ -1,7 +1,8 @@ -import moment from "moment"; import { MouseEventHandler, useCallback } from "react"; import { Link, useNavigate } from "react-router"; +const dateFormatter = new Intl.DateTimeFormat("ja", { year: "numeric", month: "long", day: "numeric" }); + import { ImageArea } from "@web-speed-hackathon-2026/client/src/components/post/ImageArea"; import { MovieArea } from "@web-speed-hackathon-2026/client/src/components/post/MovieArea"; import { SoundArea } from "@web-speed-hackathon-2026/client/src/components/post/SoundArea"; @@ -28,9 +29,10 @@ const isClickedAnchorOrButton = (target: EventTarget | null, currentTarget: Elem */ interface Props { post: Models.Post; + priority?: boolean; } -export const TimelineItem = ({ post }: Props) => { +export const TimelineItem = ({ post, priority }: Props) => { const navigate = useNavigate(); /** @@ -47,7 +49,11 @@ export const TimelineItem = ({ post }: Props) => { ); return ( -
+
{ > {post.user.profileImage.alt}
@@ -76,8 +86,8 @@ export const TimelineItem = ({ post }: Props) => { - -
{post.images?.length > 0 ? (
- +
) : null} {post.movie ? ( diff --git a/application/client/src/components/user_profile/UserProfileHeader.tsx b/application/client/src/components/user_profile/UserProfileHeader.tsx index c1c3355e19..c897a3a14a 100644 --- a/application/client/src/components/user_profile/UserProfileHeader.tsx +++ b/application/client/src/components/user_profile/UserProfileHeader.tsx @@ -1,5 +1,4 @@ import { FastAverageColor } from "fast-average-color"; -import moment from "moment"; import { ReactEventHandler, useCallback, useState } from "react"; import { FontAwesomeIcon } from "@web-speed-hackathon-2026/client/src/components/foundation/FontAwesomeIcon"; @@ -43,8 +42,8 @@ export const UserProfileHeader = ({ user }: Props) => { - diff --git a/application/client/src/containers/AppContainer.tsx b/application/client/src/containers/AppContainer.tsx index d66858a949..4216f00ddb 100644 --- a/application/client/src/containers/AppContainer.tsx +++ b/application/client/src/containers/AppContainer.tsx @@ -1,21 +1,22 @@ -import { useCallback, useEffect, useId, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useId, useState } from "react"; import { Helmet, HelmetProvider } from "react-helmet"; import { Route, Routes, useLocation, useNavigate } from "react-router"; import { AppPage } from "@web-speed-hackathon-2026/client/src/components/application/AppPage"; import { AuthModalContainer } from "@web-speed-hackathon-2026/client/src/containers/AuthModalContainer"; -import { CrokContainer } from "@web-speed-hackathon-2026/client/src/containers/CrokContainer"; -import { DirectMessageContainer } from "@web-speed-hackathon-2026/client/src/containers/DirectMessageContainer"; -import { DirectMessageListContainer } from "@web-speed-hackathon-2026/client/src/containers/DirectMessageListContainer"; -import { NewPostModalContainer } from "@web-speed-hackathon-2026/client/src/containers/NewPostModalContainer"; -import { NotFoundContainer } from "@web-speed-hackathon-2026/client/src/containers/NotFoundContainer"; -import { PostContainer } from "@web-speed-hackathon-2026/client/src/containers/PostContainer"; -import { SearchContainer } from "@web-speed-hackathon-2026/client/src/containers/SearchContainer"; -import { TermContainer } from "@web-speed-hackathon-2026/client/src/containers/TermContainer"; -import { TimelineContainer } from "@web-speed-hackathon-2026/client/src/containers/TimelineContainer"; -import { UserProfileContainer } from "@web-speed-hackathon-2026/client/src/containers/UserProfileContainer"; import { fetchJSON, sendJSON } from "@web-speed-hackathon-2026/client/src/utils/fetchers"; +const TimelineContainer = lazy(() => import("@web-speed-hackathon-2026/client/src/containers/TimelineContainer").then((m) => ({ default: m.TimelineContainer }))); +const DirectMessageListContainer = lazy(() => import("@web-speed-hackathon-2026/client/src/containers/DirectMessageListContainer").then((m) => ({ default: m.DirectMessageListContainer }))); +const DirectMessageContainer = lazy(() => import("@web-speed-hackathon-2026/client/src/containers/DirectMessageContainer").then((m) => ({ default: m.DirectMessageContainer }))); +const SearchContainer = lazy(() => import("@web-speed-hackathon-2026/client/src/containers/SearchContainer").then((m) => ({ default: m.SearchContainer }))); +const UserProfileContainer = lazy(() => import("@web-speed-hackathon-2026/client/src/containers/UserProfileContainer").then((m) => ({ default: m.UserProfileContainer }))); +const PostContainer = lazy(() => import("@web-speed-hackathon-2026/client/src/containers/PostContainer").then((m) => ({ default: m.PostContainer }))); +const TermContainer = lazy(() => import("@web-speed-hackathon-2026/client/src/containers/TermContainer").then((m) => ({ default: m.TermContainer }))); +const NotFoundContainer = lazy(() => import("@web-speed-hackathon-2026/client/src/containers/NotFoundContainer").then((m) => ({ default: m.NotFoundContainer }))); +const CrokContainer = lazy(() => import("@web-speed-hackathon-2026/client/src/containers/CrokContainer").then((m) => ({ default: m.CrokContainer }))); +const NewPostModalContainer = lazy(() => import("@web-speed-hackathon-2026/client/src/containers/NewPostModalContainer").then((m) => ({ default: m.NewPostModalContainer }))); + export const AppContainer = () => { const { pathname } = useLocation(); const navigate = useNavigate(); @@ -24,16 +25,24 @@ export const AppContainer = () => { }, [pathname]); const [activeUser, setActiveUser] = useState(null); - const [isLoadingActiveUser, setIsLoadingActiveUser] = useState(true); useEffect(() => { - void fetchJSON("/api/v1/me") - .then((user) => { - setActiveUser(user); - }) - .finally(() => { - setIsLoadingActiveUser(false); + // プリフェッチ済みなら即座に使用、なければfetch + const prefetched = window.__PREFETCH__?.["/api/v1/me"]; + if (prefetched) { + delete window.__PREFETCH__!["/api/v1/me"]; + void (prefetched as Promise).then((user) => { + if (user) setActiveUser(user); }); - }, [setActiveUser, setIsLoadingActiveUser]); + } else { + void fetchJSON("/api/v1/me") + .then((user) => { + setActiveUser(user); + }) + .catch(() => { + // Not logged in + }); + } + }, []); const handleLogout = useCallback(async () => { await sendJSON("/api/v1/signout", {}); setActiveUser(null); @@ -43,16 +52,6 @@ export const AppContainer = () => { const authModalId = useId(); const newPostModalId = useId(); - if (isLoadingActiveUser) { - return ( - - - 読込中 - CaX - - - ); - } - return ( { newPostModalId={newPostModalId} onLogout={handleLogout} > - - } path="/" /> - - } - path="/dm" - /> - } - path="/dm/:conversationId" - /> - } path="/search" /> - } path="/users/:username" /> - } path="/posts/:postId" /> - } path="/terms" /> - } - path="/crok" - /> - } path="*" /> - + + + } path="/" /> + + } + path="/dm" + /> + } + path="/dm/:conversationId" + /> + } path="/search" /> + } path="/users/:username" /> + } path="/posts/:postId" /> + } path="/terms" /> + } + path="/crok" + /> + } path="*" /> + + - + + + ); }; diff --git a/application/client/src/containers/AuthModalContainer.tsx b/application/client/src/containers/AuthModalContainer.tsx index 8d159f3528..0b7a9358fd 100644 --- a/application/client/src/containers/AuthModalContainer.tsx +++ b/application/client/src/containers/AuthModalContainer.tsx @@ -16,23 +16,21 @@ const ERROR_MESSAGES: Record = { USERNAME_TAKEN: "ユーザー名が使われています", }; -function getErrorCode(err: JQuery.jqXHR, type: "signin" | "signup"): string { - const responseJSON = err.responseJSON; +function getErrorCode(err: unknown, type: "signin" | "signup"): string { if ( - typeof responseJSON !== "object" || - responseJSON === null || - !("code" in responseJSON) || - typeof responseJSON.code !== "string" || - !Object.keys(ERROR_MESSAGES).includes(responseJSON.code) + typeof err === "object" && + err !== null && + "code" in err && + typeof (err as Record).code === "string" && + Object.keys(ERROR_MESSAGES).includes((err as Record).code) ) { - if (type === "signup") { - return "登録に失敗しました"; - } else { - return "パスワードが異なります"; - } + return ERROR_MESSAGES[(err as Record).code]!; + } + if (type === "signup") { + return "登録に失敗しました"; + } else { + return "パスワードが異なります"; } - - return ERROR_MESSAGES[responseJSON.code]!; } export const AuthModalContainer = ({ id, onUpdateActiveUser }: Props) => { @@ -68,7 +66,7 @@ export const AuthModalContainer = ({ id, onUpdateActiveUser }: Props) => { } handleRequestCloseModal(); } catch (err: unknown) { - const error = getErrorCode(err as JQuery.jqXHR, values.type); + const error = getErrorCode(err, values.type); throw new SubmissionError({ _error: error, }); diff --git a/application/client/src/hooks/use_infinite_fetch.ts b/application/client/src/hooks/use_infinite_fetch.ts index 394fccd9ea..618dc30369 100644 --- a/application/client/src/hooks/use_infinite_fetch.ts +++ b/application/client/src/hooks/use_infinite_fetch.ts @@ -36,11 +36,14 @@ export function useInfiniteFetch( offset, }; - void fetcher(apiPath).then( - (allData) => { + const separator = apiPath.includes("?") ? "&" : "?"; + const url = `${apiPath}${separator}limit=${LIMIT}&offset=${offset}`; + + void fetcher(url).then( + (pageData) => { setResult((cur) => ({ ...cur, - data: [...cur.data, ...allData.slice(offset, offset + LIMIT)], + data: [...cur.data, ...pageData], isLoading: false, })); internalRef.current = { diff --git a/application/client/src/index.css b/application/client/src/index.css index 8612ebcdd2..9007c86304 100644 --- a/application/client/src/index.css +++ b/application/client/src/index.css @@ -1,20 +1,183 @@ -@layer normalize, theme, base, components, utilities; +@import "tailwindcss"; +@import "normalize.css"; -@import "normalize.css" layer(normalize); +@theme { + --color-cax-canvas: var(--color-stone-100); + --color-cax-surface: var(--color-white); + --color-cax-surface-raised: var(--color-white); + --color-cax-surface-subtle: var(--color-stone-50); + --color-cax-overlay: var(--color-slate-950); + --color-cax-border: var(--color-stone-300); + --color-cax-border-strong: var(--color-stone-400); + --color-cax-text: var(--color-teal-950); + --color-cax-text-muted: var(--color-teal-700); + --color-cax-text-subtle: var(--color-slate-500); + --color-cax-brand: var(--color-teal-700); + --color-cax-brand-strong: var(--color-teal-800); + --color-cax-brand-soft: var(--color-teal-100); + --color-cax-accent: var(--color-orange-700); + --color-cax-accent-soft: var(--color-orange-100); + --color-cax-danger: var(--color-red-600); + --color-cax-danger-soft: var(--color-red-100); + --color-cax-highlight: var(--color-amber-200); + --color-cax-highlight-ink: var(--color-amber-950); +} + +@layer base { + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } +} + +@utility markdown { + @apply text-sm wrap-anywhere; + line-break: strict; + + /* インライン要素 */ + :where(a) { + @apply text-cax-accent decoration-cax-accent underline underline-offset-2; + } + :where(strong) { + @apply font-bold; + } + :where(em) { + @apply italic; + } + :where(code):not(:where(pre > code)) { + @apply bg-cax-surface-subtle text-cax-text rounded px-1 py-0.5 font-mono; + } + :where(del) { + @apply decoration-cax-text-subtle line-through; + } + + /* ブロック要素 */ + :where(p) { + @apply text-cax-text my-6; + } + :where(blockquote) { + @apply border-cax-border text-cax-text-muted my-6 border-l-4 pl-4; + } + :where(hr) { + @apply border-cax-border my-10 border-t; + } + + /* リスト */ + :where(ol) { + @apply my-6 list-decimal pl-6; + } + :where(ul) { + @apply my-6 list-disc pl-6; + } + :where(li) { + @apply my-2; + } + :where(ol > li, ul > li)::marker { + @apply text-cax-text-muted; + } + :where(ol ol, ul ul, ol ul, ul ol) { + @apply my-2; + } + + /* テーブル */ + :where(table) { + @apply text-cax-text my-6 w-full table-auto text-sm; + } + :where(thead) { + @apply border-cax-border border-b; + } + :where(thead th) { + @apply px-2 pb-1.5 font-bold; + } + :where(tbody tr) { + @apply border-cax-border border-b; + } + :where(tbody tr:last-child) { + @apply border-b-0; + } + :where(tbody td) { + @apply align-baseline; + } + :where(tfoot) { + @apply border-cax-border border-t; + } + :where(tfoot td) { + @apply align-top; + } + :where(th, td) { + @apply text-left; + } + :where(tbody td, tfoot td) { + @apply px-2 py-1.5; + } + :where(tbody tr:last-child td, tfoot tr:last-child td) { + @apply pb-0; + } + + /* 見出し */ + :where(h1, h2, h3, h4, h5, h6) { + @apply text-cax-text text-pretty; + } + :where(h1, h2, h3, h4) { + @apply font-bold; + } + :where(h1, h2) { + @apply mt-10 mb-8 text-2xl; + } + :where(h3) { + @apply mt-8 mb-6 text-xl; + } + :where(h4) { + @apply mt-6 mb-4 text-lg; + } + :where(h5) { + @apply border-cax-border mt-6 mb-4 border-b py-0.5 pl-2 font-bold; + } + :where(h6) { + @apply border-cax-border mt-6 mb-4 border-b py-0.5 pl-2; + } + :where(h1 + *, h2 + *, h3 + *, h4 + *, h5 + *, h6 + *) { + @apply mt-0; + } + + /* 注釈 */ + :where(.footnotes) { + @apply border-cax-border mt-8 border-t; + } + :where(.footnotes h2) { + @apply sr-only; + } + :where(.footnotes ol) { + @apply mt-8 mb-0 text-sm; + } + :where(.footnotes ol li p) { + @apply my-0; + } + + /* 最初の要素はマージンを0にする */ + & > *:first-child { + @apply mt-0; + } + + /* 最後の要素はマージンを0にする */ + & > *:last-child { + @apply mb-0; + } +} @font-face { /* Source Han Serif JP Regular の Y 軸を 1/1.43 に縮小した改変フォント */ font-family: "Rei no Are Mincho"; - font-display: block; - src: url(/fonts/ReiNoAreMincho-Regular.otf) format("opentype"); + font-display: swap; + src: url(/fonts/ReiNoAreMincho-Regular-subset.woff2) format("woff2"); font-weight: normal; } @font-face { /* Source Han Serif JP Heavy の Y 軸を 1/1.43 に縮小した改変フォント */ font-family: "Rei no Are Mincho"; - font-display: block; - src: url(/fonts/ReiNoAreMincho-Heavy.otf) format("opentype"); + font-display: swap; + src: url(/fonts/ReiNoAreMincho-Heavy-subset.woff2) format("woff2"); font-weight: bold; } diff --git a/application/client/src/index.html b/application/client/src/index.html index 3d949e7473..205b7ea168 100644 --- a/application/client/src/index.html +++ b/application/client/src/index.html @@ -4,176 +4,15 @@ CaX - - - - + + + + -
+

読込中...

diff --git a/application/client/src/index.tsx b/application/client/src/index.tsx index b1833b0af3..8e8a27c516 100644 --- a/application/client/src/index.tsx +++ b/application/client/src/index.tsx @@ -5,12 +5,29 @@ import { BrowserRouter } from "react-router"; import { AppContainer } from "@web-speed-hackathon-2026/client/src/containers/AppContainer"; import { store } from "@web-speed-hackathon-2026/client/src/store"; -window.addEventListener("load", () => { - createRoot(document.getElementById("app")!).render( - - - - - , - ); -}); +// Polyfill for HTML Invoker Commands API (command/commandfor) +if (typeof HTMLButtonElement !== "undefined" && !("commandForElement" in HTMLButtonElement.prototype)) { + document.addEventListener("click", (e) => { + const button = (e.target as Element)?.closest?.("button[commandfor]"); + if (!button) return; + const command = button.getAttribute("command"); + const targetId = button.getAttribute("commandfor"); + if (!targetId) return; + const target = document.getElementById(targetId); + if (!target || !(target instanceof HTMLDialogElement)) return; + if (command === "show-modal" && !target.open) { + target.showModal(); + } else if (command === "close") { + target.close(); + } + }); +} + +// defer属性付きのため DOMContentLoaded 不要 - 即座にレンダリング開始 +createRoot(document.getElementById("app")!).render( + + + + + , +); diff --git a/application/client/src/utils/bm25_search.ts b/application/client/src/utils/bm25_search.ts index c590d12c09..6bd95c5980 100644 --- a/application/client/src/utils/bm25_search.ts +++ b/application/client/src/utils/bm25_search.ts @@ -1,6 +1,5 @@ import { BM25 } from "bayesian-bm25"; import type { Tokenizer, IpadicFeatures } from "kuromoji"; -import _ from "lodash"; const STOP_POS = new Set(["助詞", "助動詞", "記号"]); @@ -28,15 +27,12 @@ export function filterSuggestionsBM25( const tokenizedCandidates = candidates.map((c) => extractTokens(tokenizer.tokenize(c))); bm25.index(tokenizedCandidates); - const results = _.zipWith(candidates, bm25.getScores(queryTokens), (text, score) => { - return { text, score }; - }); + const scores = bm25.getScores(queryTokens); + const results = candidates.map((text, i) => ({ text, score: scores[i] })); - // スコアが高い(=類似度が高い)ものが下に来るように、上位10件を取得する - return _(results) + return results .filter((s) => s.score > 0) - .sortBy(["score"]) + .sort((a, b) => a.score - b.score) .slice(-10) - .map((s) => s.text) - .value(); + .map((s) => s.text); } diff --git a/application/client/src/utils/convert_image.ts b/application/client/src/utils/convert_image.ts index 9fce086d9c..014fd31681 100644 --- a/application/client/src/utils/convert_image.ts +++ b/application/client/src/utils/convert_image.ts @@ -1,5 +1,5 @@ import { initializeImageMagick, ImageMagick, MagickFormat } from "@imagemagick/magick-wasm"; -import magickWasm from "@imagemagick/magick-wasm/magick.wasm?binary"; +import magickWasmUrl from "@imagemagick/magick-wasm/magick.wasm?binary"; import { dump, insert, ImageIFD } from "piexifjs"; interface Options { @@ -7,7 +7,8 @@ interface Options { } export async function convertImage(file: File, options: Options): Promise { - await initializeImageMagick(magickWasm); + const wasmBinary = await fetch(magickWasmUrl).then((r) => r.arrayBuffer()); + await initializeImageMagick(new Uint8Array(wasmBinary)); const byteArray = new Uint8Array(await file.arrayBuffer()); diff --git a/application/client/src/utils/create_translator.ts b/application/client/src/utils/create_translator.ts index ad1dabad22..1dbae08948 100644 --- a/application/client/src/utils/create_translator.ts +++ b/application/client/src/utils/create_translator.ts @@ -1,4 +1,3 @@ -import { CreateMLCEngine } from "@mlc-ai/web-llm"; import { stripIndents } from "common-tags"; import * as JSONRepairJS from "json-repair-js"; import langs from "langs"; @@ -21,6 +20,7 @@ export async function createTranslator(params: Params): Promise { const targetLang = langs.where("1", params.targetLanguage); invariant(targetLang, `Unsupported target language code: ${params.targetLanguage}`); + const { CreateMLCEngine } = await import("@mlc-ai/web-llm"); const engine = await CreateMLCEngine("gemma-2-2b-jpn-it-q4f16_1-MLC"); return { diff --git a/application/client/src/utils/fetchers.ts b/application/client/src/utils/fetchers.ts index 92a14f408f..ec5486f15e 100644 --- a/application/client/src/utils/fetchers.ts +++ b/application/client/src/utils/fetchers.ts @@ -1,58 +1,42 @@ -import $ from "jquery"; -import { gzip } from "pako"; - export async function fetchBinary(url: string): Promise { - const result = await $.ajax({ - async: false, - dataType: "binary", - method: "GET", - responseType: "arraybuffer", - url, - }); - return result; + const response = await fetch(url); + return response.arrayBuffer(); +} + +declare global { + interface Window { + __PREFETCH__?: Record>; + } } export async function fetchJSON(url: string): Promise { - const result = await $.ajax({ - async: false, - dataType: "json", - method: "GET", - url, - }); - return result; + // プリフェッチ済みデータがあればそれを使う + const prefetched = window.__PREFETCH__?.[url]; + if (prefetched) { + delete window.__PREFETCH__![url]; + return prefetched as Promise; + } + const response = await fetch(url); + if (!response.ok) throw await response.json().catch(() => new Error(response.statusText)); + return response.json(); } export async function sendFile(url: string, file: File): Promise { - const result = await $.ajax({ - async: false, - data: file, - dataType: "json", - headers: { - "Content-Type": "application/octet-stream", - }, + const response = await fetch(url, { method: "POST", - processData: false, - url, + headers: { "Content-Type": "application/octet-stream" }, + body: file, }); - return result; + if (!response.ok) throw await response.json().catch(() => new Error(response.statusText)); + return response.json(); } export async function sendJSON(url: string, data: object): Promise { - const jsonString = JSON.stringify(data); - const uint8Array = new TextEncoder().encode(jsonString); - const compressed = gzip(uint8Array); - - const result = await $.ajax({ - async: false, - data: compressed, - dataType: "json", - headers: { - "Content-Encoding": "gzip", - "Content-Type": "application/json", - }, + const response = await fetch(url, { method: "POST", - processData: false, - url, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), }); - return result; + if (!response.ok) throw await response.json().catch(() => new Error(response.statusText)); + return response.json(); } diff --git a/application/client/src/utils/load_ffmpeg.ts b/application/client/src/utils/load_ffmpeg.ts index f923a3d5a4..e40c942c07 100644 --- a/application/client/src/utils/load_ffmpeg.ts +++ b/application/client/src/utils/load_ffmpeg.ts @@ -4,12 +4,8 @@ export async function loadFFmpeg(): Promise { const ffmpeg = new FFmpeg(); await ffmpeg.load({ - coreURL: await import("@ffmpeg/core?binary").then(({ default: b }) => { - return URL.createObjectURL(new Blob([b], { type: "text/javascript" })); - }), - wasmURL: await import("@ffmpeg/core/wasm?binary").then(({ default: b }) => { - return URL.createObjectURL(new Blob([b], { type: "application/wasm" })); - }), + coreURL: (await import("@ffmpeg/core?binary")).default, + wasmURL: (await import("@ffmpeg/core/wasm?binary")).default, }); return ffmpeg; diff --git a/application/client/src/utils/negaposi_analyzer.ts b/application/client/src/utils/negaposi_analyzer.ts index f81ed5f4ea..248161aa4b 100644 --- a/application/client/src/utils/negaposi_analyzer.ts +++ b/application/client/src/utils/negaposi_analyzer.ts @@ -1,19 +1,17 @@ -import Bluebird from "bluebird"; -import kuromoji, { type Tokenizer, type IpadicFeatures } from "kuromoji"; -import analyze from "negaposi-analyzer-ja"; - -async function getTokenizer(): Promise> { - const builder = Bluebird.promisifyAll(kuromoji.builder({ dicPath: "/dicts" })); - return await builder.buildAsync(); -} - type SentimentResult = { score: number; label: "positive" | "negative" | "neutral"; }; export async function analyzeSentiment(text: string): Promise { - const tokenizer = await getTokenizer(); + const [Bluebird, kuromoji, { default: analyze }] = await Promise.all([ + import("bluebird"), + import("kuromoji"), + import("negaposi-analyzer-ja"), + ]); + + const builder = Bluebird.default.promisifyAll(kuromoji.default.builder({ dicPath: "/dicts" })); + const tokenizer = await builder.buildAsync(); const tokens = tokenizer.tokenize(text); const score = analyze(tokens); diff --git a/application/client/webpack.config.js b/application/client/webpack.config.js index 9fae72647f..6413132c9f 100644 --- a/application/client/webpack.config.js +++ b/application/client/webpack.config.js @@ -25,18 +25,15 @@ const config = { ], static: [PUBLIC_PATH, UPLOAD_PATH], }, - devtool: "inline-source-map", + devtool: false, entry: { main: [ - "core-js", - "regenerator-runtime/runtime", - "jquery-binarytransport", path.resolve(SRC_PATH, "./index.css"), path.resolve(SRC_PATH, "./buildinfo.ts"), path.resolve(SRC_PATH, "./index.tsx"), ], }, - mode: "none", + mode: "production", module: { rules: [ { @@ -54,30 +51,26 @@ const config = { }, { resourceQuery: /binary/, - type: "asset/bytes", + type: "asset/resource", + generator: { + filename: "assets/[hash][ext]", + }, }, ], }, output: { chunkFilename: "scripts/chunk-[contenthash].js", - chunkFormat: false, filename: "scripts/[name].js", path: DIST_PATH, - publicPath: "auto", + publicPath: "/", clean: true, }, plugins: [ - new webpack.ProvidePlugin({ - $: "jquery", - AudioContext: ["standardized-audio-context", "AudioContext"], - Buffer: ["buffer", "Buffer"], - "window.jQuery": "jquery", - }), new webpack.EnvironmentPlugin({ BUILD_DATE: new Date().toISOString(), // Heroku では SOURCE_VERSION 環境変数から commit hash を参照できます COMMIT_HASH: process.env.SOURCE_VERSION || "", - NODE_ENV: "development", + NODE_ENV: "production", }), new MiniCssExtractPlugin({ filename: "styles/[name].css", @@ -87,11 +80,15 @@ const config = { { from: path.resolve(__dirname, "node_modules/katex/dist/fonts"), to: path.resolve(DIST_PATH, "styles/fonts"), + globOptions: { + ignore: ["**/*.ttf", "**/*.woff"], + }, }, ], }), new HtmlWebpackPlugin({ - inject: false, + inject: true, + scriptLoading: "defer", template: path.resolve(SRC_PATH, "./index.html"), }), ], @@ -128,14 +125,15 @@ const config = { }, }, optimization: { - minimize: false, - splitChunks: false, - concatenateModules: false, - usedExports: false, - providedExports: false, - sideEffects: false, + minimize: true, + splitChunks: { + chunks: "all", + }, + concatenateModules: true, + usedExports: true, + providedExports: true, + sideEffects: true, }, - cache: false, ignoreWarnings: [ { module: /@ffmpeg/, diff --git a/application/pnpm-lock.yaml b/application/pnpm-lock.yaml index 510570f5c9..50b24758d7 100644 --- a/application/pnpm-lock.yaml +++ b/application/pnpm-lock.yaml @@ -62,7 +62,7 @@ importers: version: 9.5.0 gifler: specifier: github:themadcreator/gifler#v0.3.0 - version: https://codeload.github.com/themadcreator/gifler/tar.gz/c3259b071c7782f85d4928a5f03d0b378ed003b5 + version: https://codeload.github.com/themadcreator/gifler/tar.gz/89484cb3db174c584a3138e89664f0167a7760c1 image-size: specifier: 2.0.2 version: 2.0.2 @@ -160,6 +160,9 @@ importers: '@babel/preset-typescript': specifier: 7.27.1 version: 7.27.1(@babel/core@7.28.4) + '@tailwindcss/postcss': + specifier: 4.2.2 + version: 4.2.2 '@tsconfig/strictest': specifier: 2.0.8 version: 2.0.8 @@ -238,6 +241,9 @@ importers: react-markdown: specifier: 10.1.0 version: 10.1.0(@types/react@19.2.2)(react@19.2.0) + tailwindcss: + specifier: 4.2.2 + version: 4.2.2 typescript: specifier: 5.9.3 version: 5.9.3 @@ -268,6 +274,9 @@ importers: '@tsconfig/strictest': specifier: 2.0.8 version: 2.0.8 + '@types/compression': + specifier: 1.8.1 + version: 1.8.1 '@web-speed-hackathon-2026/server': specifier: workspace:* version: 'link:' @@ -277,6 +286,9 @@ importers: body-parser: specifier: 2.2.0 version: 2.2.0 + compression: + specifier: 1.8.1 + version: 1.8.1 connect-history-api-fallback: specifier: 2.0.0 version: 2.0.0 @@ -301,6 +313,9 @@ importers: serve-static: specifier: 2.2.0 version: 2.2.0 + sharp: + specifier: 0.34.5 + version: 0.34.5 sqlite3: specifier: 5.1.7 version: 5.1.7 @@ -350,6 +365,10 @@ importers: packages: + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -1190,6 +1209,9 @@ packages: peerDependencies: react: '19' + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + '@esbuild/aix-ppc64@0.25.11': resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} engines: {node: '>=18'} @@ -1368,6 +1390,159 @@ packages: '@imagemagick/magick-wasm@0.0.37': resolution: {integrity: sha512-tVs9hcWu9u7I3Jz/XvUYVvCEniuxAR+JjZEzI+yKtQmYAtNsLF1WjoH1HZGCKPumaB9jAHZlcf2RGT9+1l3nxQ==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1608,6 +1783,98 @@ packages: engines: {node: '>=18'} hasBin: true + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.2.2': + resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -1637,6 +1904,9 @@ packages: '@types/common-tags@1.8.4': resolution: {integrity: sha512-S+1hLDJPjWNDhcGxsxEbepzaxWqURP/o+3cP4aa2w7yBXgdcmKGQtZzP8JbyfOd0m+33nh+8+kvxYE2UJtBDkg==} + '@types/compression@1.8.1': + resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==} + '@types/connect-history-api-fallback@1.5.4': resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==} @@ -2426,6 +2696,10 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} @@ -2667,8 +2941,8 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} - gifler@https://codeload.github.com/themadcreator/gifler/tar.gz/c3259b071c7782f85d4928a5f03d0b378ed003b5: - resolution: {tarball: https://codeload.github.com/themadcreator/gifler/tar.gz/c3259b071c7782f85d4928a5f03d0b378ed003b5} + gifler@https://codeload.github.com/themadcreator/gifler/tar.gz/89484cb3db174c584a3138e89664f0167a7760c1: + resolution: {tarball: https://codeload.github.com/themadcreator/gifler/tar.gz/89484cb3db174c584a3138e89664f0167a7760c1} version: 0.3.0 github-from-package@0.0.0: @@ -3075,6 +3349,80 @@ packages: launch-editor@2.11.1: resolution: {integrity: sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -3120,6 +3468,9 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-fetch-happen@9.1.0: resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==} engines: {node: '>= 10'} @@ -4209,6 +4560,10 @@ packages: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} engines: {node: '>=8'} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4343,6 +4698,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} @@ -4691,6 +5049,8 @@ packages: snapshots: + '@alloc/quick-lru@5.2.0': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -5717,6 +6077,11 @@ snapshots: '@babel/runtime': 7.28.4 react: 19.2.0 + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.25.11': optional: true @@ -5810,6 +6175,102 @@ snapshots: '@imagemagick/magick-wasm@0.0.37': {} + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5972,6 +6433,75 @@ snapshots: dependencies: playwright: 1.50.1 + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/postcss@4.2.2': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + postcss: 8.5.6 + tailwindcss: 4.2.2 + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -6003,6 +6533,11 @@ snapshots: '@types/common-tags@1.8.4': {} + '@types/compression@1.8.1': + dependencies: + '@types/express': 5.0.3 + '@types/node': 22.18.8 + '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 5.1.0 @@ -6856,6 +7391,11 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + entities@2.2.0: {} entities@6.0.1: {} @@ -7161,7 +7701,7 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - gifler@https://codeload.github.com/themadcreator/gifler/tar.gz/c3259b071c7782f85d4928a5f03d0b378ed003b5: + gifler@https://codeload.github.com/themadcreator/gifler/tar.gz/89484cb3db174c584a3138e89664f0167a7760c1: dependencies: bluebird: 3.7.2 omggif: 1.0.10 @@ -7593,6 +8133,55 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.8.3 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lines-and-columns@1.2.4: {} loader-runner@4.3.1: {} @@ -7635,6 +8224,10 @@ snapshots: yallist: 4.0.0 optional: true + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + make-fetch-happen@9.1.0: dependencies: agentkeepalive: 4.6.0 @@ -9144,6 +9737,37 @@ snapshots: dependencies: kind-of: 6.0.3 + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -9320,6 +9944,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tailwindcss@4.2.2: {} + tapable@2.3.0: {} tar-fs@2.1.4: diff --git a/application/pnpm-workspace.yaml b/application/pnpm-workspace.yaml index 23813fbebd..8b55d07146 100644 --- a/application/pnpm-workspace.yaml +++ b/application/pnpm-workspace.yaml @@ -8,6 +8,7 @@ allowBuilds: esbuild: true sqlite3: true negaposi-analyzer-ja: true + sharp: true overrides: gifler>bluebird: 3.7.2 gifler>omggif: 1.0.10 diff --git a/application/public/fonts/ReiNoAreMincho-Heavy-subset.woff2 b/application/public/fonts/ReiNoAreMincho-Heavy-subset.woff2 new file mode 100644 index 0000000000..9123baf126 Binary files /dev/null and b/application/public/fonts/ReiNoAreMincho-Heavy-subset.woff2 differ diff --git a/application/public/fonts/ReiNoAreMincho-Heavy.otf b/application/public/fonts/ReiNoAreMincho-Heavy.otf deleted file mode 100644 index 5634d00be9..0000000000 Binary files a/application/public/fonts/ReiNoAreMincho-Heavy.otf and /dev/null differ diff --git a/application/public/fonts/ReiNoAreMincho-Regular-subset.woff2 b/application/public/fonts/ReiNoAreMincho-Regular-subset.woff2 new file mode 100644 index 0000000000..29b3dc0322 Binary files /dev/null and b/application/public/fonts/ReiNoAreMincho-Regular-subset.woff2 differ diff --git a/application/public/fonts/ReiNoAreMincho-Regular.otf b/application/public/fonts/ReiNoAreMincho-Regular.otf deleted file mode 100644 index c210e7a9ee..0000000000 Binary files a/application/public/fonts/ReiNoAreMincho-Regular.otf and /dev/null differ diff --git a/application/public/sprites/font-awesome/brands.svg b/application/public/sprites/font-awesome/brands.svg index 93e47533cc..b2c50fcdfc 100644 --- a/application/public/sprites/font-awesome/brands.svg +++ b/application/public/sprites/font-awesome/brands.svg @@ -1,1381 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/application/public/sprites/font-awesome/regular.svg b/application/public/sprites/font-awesome/regular.svg index e257deeb17..212954e9c9 100644 --- a/application/public/sprites/font-awesome/regular.svg +++ b/application/public/sprites/font-awesome/regular.svg @@ -1,463 +1,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/application/public/sprites/font-awesome/solid.svg b/application/public/sprites/font-awesome/solid.svg index 19ae7e2656..201ad35801 100644 --- a/application/public/sprites/font-awesome/solid.svg +++ b/application/public/sprites/font-awesome/solid.svg @@ -1,3013 +1,54 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + \ No newline at end of file diff --git a/application/server/package.json b/application/server/package.json index 9482575df7..4aead1ccbc 100644 --- a/application/server/package.json +++ b/application/server/package.json @@ -14,9 +14,11 @@ }, "dependencies": { "@tsconfig/strictest": "2.0.8", + "@types/compression": "1.8.1", "@web-speed-hackathon-2026/server": "workspace:*", "bcrypt": "6.0.0", "body-parser": "2.2.0", + "compression": "1.8.1", "connect-history-api-fallback": "2.0.0", "express": "5.1.0", "express-session": "1.18.2", @@ -25,6 +27,7 @@ "music-metadata": "11.10.3", "sequelize": "6.37.7", "serve-static": "2.2.0", + "sharp": "0.34.5", "sqlite3": "5.1.7", "tsx": "4.20.6", "uuid": "13.0.0", diff --git a/application/server/src/app.ts b/application/server/src/app.ts index 671fb424cc..4d5b817a6f 100644 --- a/application/server/src/app.ts +++ b/application/server/src/app.ts @@ -1,4 +1,5 @@ import bodyParser from "body-parser"; +import compression from "compression"; import Express from "express"; import { apiRouter } from "@web-speed-hackathon-2026/server/src/routes/api"; @@ -9,17 +10,8 @@ export const app = Express(); app.set("trust proxy", true); -app.use(sessionMiddleware); -app.use(bodyParser.json()); -app.use(bodyParser.raw({ limit: "10mb" })); +app.use(compression({ level: 9 })); -app.use((_req, res, next) => { - res.header({ - "Cache-Control": "max-age=0, no-transform", - Connection: "close", - }); - return next(); -}); - -app.use("/api/v1", apiRouter); +// Session and body parsers only for API routes (skip for static files) +app.use("/api/v1", sessionMiddleware, bodyParser.json(), bodyParser.raw({ limit: "10mb" }), apiRouter); app.use(staticRouter); diff --git a/application/server/src/models/Comment.ts b/application/server/src/models/Comment.ts index 41e9e4e094..6c75cd5fa5 100644 --- a/application/server/src/models/Comment.ts +++ b/application/server/src/models/Comment.ts @@ -40,6 +40,10 @@ export function initComment(sequelize: Sequelize) { }, { sequelize, + indexes: [ + { fields: ["postId"] }, + { fields: ["userId"] }, + ], defaultScope: { attributes: { exclude: ["userId", "postId"], diff --git a/application/server/src/models/DirectMessage.ts b/application/server/src/models/DirectMessage.ts index e4565ba1c4..c174298d7b 100644 --- a/application/server/src/models/DirectMessage.ts +++ b/application/server/src/models/DirectMessage.ts @@ -60,6 +60,11 @@ export function initDirectMessage(sequelize: Sequelize) { }, { sequelize, + indexes: [ + { fields: ["conversationId"] }, + { fields: ["senderId"] }, + { fields: ["conversationId", "senderId", "isRead"] }, + ], defaultScope: { include: [ { diff --git a/application/server/src/models/DirectMessageConversation.ts b/application/server/src/models/DirectMessageConversation.ts index 99ebb2425b..dd18a551ce 100644 --- a/application/server/src/models/DirectMessageConversation.ts +++ b/application/server/src/models/DirectMessageConversation.ts @@ -46,6 +46,10 @@ export function initDirectMessageConversation(sequelize: Sequelize) { }, { sequelize, + indexes: [ + { fields: ["initiatorId"] }, + { fields: ["memberId"] }, + ], defaultScope: { include: [ { association: "initiator", include: [{ association: "profileImage" }] }, diff --git a/application/server/src/models/Post.ts b/application/server/src/models/Post.ts index 6f86442eb1..9820cd5479 100644 --- a/application/server/src/models/Post.ts +++ b/application/server/src/models/Post.ts @@ -42,6 +42,10 @@ export function initPost(sequelize: Sequelize) { }, { sequelize, + indexes: [ + { fields: ["userId"] }, + { fields: ["createdAt"] }, + ], defaultScope: { attributes: { exclude: ["userId", "movieId", "soundId"], diff --git a/application/server/src/models/PostsImagesRelation.ts b/application/server/src/models/PostsImagesRelation.ts index 0a5364b56f..649c9899bf 100644 --- a/application/server/src/models/PostsImagesRelation.ts +++ b/application/server/src/models/PostsImagesRelation.ts @@ -36,6 +36,10 @@ export function initPostsImagesRelation(sequelize: Sequelize) { }, { sequelize, + indexes: [ + { fields: ["postId"] }, + { fields: ["imageId"] }, + ], }, ); } diff --git a/application/server/src/routes/api/direct_message.ts b/application/server/src/routes/api/direct_message.ts index 2993a2d6be..34ef1a2938 100644 --- a/application/server/src/routes/api/direct_message.ts +++ b/application/server/src/routes/api/direct_message.ts @@ -16,20 +16,31 @@ directMessageRouter.get("/dm", async (req, res) => { throw new httpErrors.Unauthorized(); } - const conversations = await DirectMessageConversation.findAll({ + const conversations = await DirectMessageConversation.unscoped().findAll({ where: { - [Op.and]: [ - { [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }] }, - where(col("messages.id"), { [Op.not]: null }), - ], + [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }], }, - order: [[col("messages.createdAt"), "DESC"]], + include: [ + { association: "initiator", include: [{ association: "profileImage" }] }, + { association: "member", include: [{ association: "profileImage" }] }, + { + association: "messages", + include: [{ association: "sender", include: [{ association: "profileImage" }] }], + order: [["createdAt", "ASC"]], + required: false, + separate: true, + }, + ], }); - const sorted = conversations.map((c) => ({ - ...c.toJSON(), - messages: c.messages?.reverse(), - })); + // 最新メッセージで降順ソート + const sorted = conversations + .filter((c) => c.messages && c.messages.length > 0) + .sort((a, b) => { + const aLast = a.messages![a.messages!.length - 1]!.createdAt; + const bLast = b.messages![b.messages!.length - 1]!.createdAt; + return new Date(bLast).getTime() - new Date(aLast).getTime(); + }); return res.status(200).type("application/json").send(sorted); }); @@ -100,11 +111,23 @@ directMessageRouter.get("/dm/:conversationId", async (req, res) => { throw new httpErrors.Unauthorized(); } - const conversation = await DirectMessageConversation.findOne({ + const conversation = await DirectMessageConversation.unscoped().findOne({ where: { id: req.params.conversationId, [Op.or]: [{ initiatorId: req.session.userId }, { memberId: req.session.userId }], }, + include: [ + { association: "initiator", include: [{ association: "profileImage" }] }, + { association: "member", include: [{ association: "profileImage" }] }, + { + association: "messages", + include: [{ association: "sender", include: [{ association: "profileImage" }] }], + order: [["createdAt", "ASC"]], + required: false, + separate: true, + limit: 100, + }, + ], }); if (conversation === null) { throw new httpErrors.NotFound(); diff --git a/application/server/src/routes/api/initialize.ts b/application/server/src/routes/api/initialize.ts index de1044cef0..a78b19b187 100644 --- a/application/server/src/routes/api/initialize.ts +++ b/application/server/src/routes/api/initialize.ts @@ -6,6 +6,7 @@ import { UPLOAD_PATH } from "@web-speed-hackathon-2026/server/src/paths"; import { initializeSequelize } from "../../sequelize"; import { sessionStore } from "../../session"; +import { clearPostCache } from "./post"; export const initializeRouter = Router(); @@ -14,6 +15,8 @@ initializeRouter.post("/initialize", async (_req, res) => { await initializeSequelize(); // sessionStoreをクリア sessionStore.clear(); + // APIキャッシュクリア + clearPostCache(); // uploadディレクトリをクリア await fs.rm(UPLOAD_PATH, { force: true, recursive: true }); diff --git a/application/server/src/routes/api/post.ts b/application/server/src/routes/api/post.ts index cda8654b2b..7712410309 100644 --- a/application/server/src/routes/api/post.ts +++ b/application/server/src/routes/api/post.ts @@ -5,35 +5,77 @@ import { Comment, Post } from "@web-speed-hackathon-2026/server/src/models"; export const postRouter = Router(); +// API レスポンスキャッシュ +const apiCache = new Map(); + +export function clearPostCache() { + apiCache.clear(); +} + postRouter.get("/posts", async (req, res) => { - const posts = await Post.findAll({ - limit: req.query["limit"] != null ? Number(req.query["limit"]) : undefined, - offset: req.query["offset"] != null ? Number(req.query["offset"]) : undefined, - }); + const limit = req.query["limit"] != null ? Number(req.query["limit"]) : 30; + const offset = req.query["offset"] != null ? Number(req.query["offset"]) : 0; + const cacheKey = `posts:${limit}:${offset}`; + + const cached = apiCache.get(cacheKey); + if (cached) { + res.set("Cache-Control", "public, max-age=5"); + return res.status(200).type("application/json").send(cached); + } + + const posts = await Post.findAll({ limit, offset: offset || undefined }); + const json = JSON.stringify(posts); + apiCache.set(cacheKey, json); - return res.status(200).type("application/json").send(posts); + res.set("Cache-Control", "public, max-age=5"); + return res.status(200).type("application/json").send(json); }); postRouter.get("/posts/:postId", async (req, res) => { + const cacheKey = `post:${req.params.postId}`; + + const cached = apiCache.get(cacheKey); + if (cached) { + res.set("Cache-Control", "public, max-age=5"); + return res.status(200).type("application/json").send(cached); + } + const post = await Post.findByPk(req.params.postId); if (post === null) { throw new httpErrors.NotFound(); } - return res.status(200).type("application/json").send(post); + const json = JSON.stringify(post); + apiCache.set(cacheKey, json); + + res.set("Cache-Control", "public, max-age=5"); + return res.status(200).type("application/json").send(json); }); postRouter.get("/posts/:postId/comments", async (req, res) => { + const limit = req.query["limit"] != null ? Number(req.query["limit"]) : 30; + const offset = req.query["offset"] != null ? Number(req.query["offset"]) : 0; + const cacheKey = `comments:${req.params.postId}:${limit}:${offset}`; + + const cached = apiCache.get(cacheKey); + if (cached) { + res.set("Cache-Control", "public, max-age=5"); + return res.status(200).type("application/json").send(cached); + } + const posts = await Comment.findAll({ - limit: req.query["limit"] != null ? Number(req.query["limit"]) : undefined, - offset: req.query["offset"] != null ? Number(req.query["offset"]) : undefined, + limit, + offset: offset || undefined, where: { postId: req.params.postId, }, }); + const json = JSON.stringify(posts); + apiCache.set(cacheKey, json); - return res.status(200).type("application/json").send(posts); + res.set("Cache-Control", "public, max-age=5"); + return res.status(200).type("application/json").send(json); }); postRouter.post("/posts", async (req, res) => { @@ -58,5 +100,8 @@ postRouter.post("/posts", async (req, res) => { }, ); + // 新しい投稿が追加されたらキャッシュをクリア + clearPostCache(); + return res.status(200).type("application/json").send(post); }); diff --git a/application/server/src/routes/static.ts b/application/server/src/routes/static.ts index b5820c986e..a64e863b78 100644 --- a/application/server/src/routes/static.ts +++ b/application/server/src/routes/static.ts @@ -1,5 +1,9 @@ +import fs from "fs"; +import path from "path"; + import history from "connect-history-api-fallback"; import { Router } from "express"; +import sharp from "sharp"; import serveStatic from "serve-static"; import { @@ -10,26 +14,138 @@ import { export const staticRouter = Router(); +// 画像最適化: JPEG画像をWebPに変換して配信 +const imageCache = new Map(); + +staticRouter.use(async (req, res, next) => { + // /images/*.jpg パスのみ処理 + if (!req.path.startsWith("/images/") || !req.path.endsWith(".jpg")) { + return next(); + } + + const accept = req.headers.accept || ""; + const supportsWebP = accept.includes("image/webp"); + const supportsAvif = accept.includes("image/avif"); + + const relativePath = req.path.slice(1); // "images/..." に変換 + const imagePath = path.join(PUBLIC_PATH, relativePath); + + if (!fs.existsSync(imagePath)) { + return next(); + } + + const isProfile = req.path.startsWith("/images/profiles/"); + const format = supportsAvif ? "avif" as const : supportsWebP ? "webp" as const : "jpeg" as const; + const cacheKey = `${relativePath}:${format}`; + + if (imageCache.has(cacheKey)) { + const cached = imageCache.get(cacheKey)!; + const contentType = format === "avif" ? "image/avif" : format === "webp" ? "image/webp" : "image/jpeg"; + res.set("Content-Type", contentType); + res.set("Cache-Control", "public, max-age=604800, immutable"); + res.set("Vary", "Accept"); + return res.send(cached); + } + + try { + let pipeline = sharp(imagePath); + + if (isProfile) { + pipeline = pipeline.resize(128, 128, { fit: "cover" }); + } else { + pipeline = pipeline.resize(960, undefined, { fit: "inside", withoutEnlargement: true }); + } + + let buffer: Buffer; + let contentType: string; + if (format === "avif") { + buffer = await pipeline.avif({ quality: 50 }).toBuffer(); + contentType = "image/avif"; + } else if (format === "webp") { + buffer = await pipeline.webp({ quality: 75 }).toBuffer(); + contentType = "image/webp"; + } else { + buffer = await pipeline.jpeg({ quality: 75, mozjpeg: true }).toBuffer(); + contentType = "image/jpeg"; + } + + imageCache.set(cacheKey, buffer); + + res.set("Content-Type", contentType); + res.set("Cache-Control", "public, max-age=604800, immutable"); + res.set("Vary", "Accept"); + return res.send(buffer); + } catch { + return next(); + } +}); + +// GIF最適化: GIFをWebPアニメーションに変換して配信 +const gifCache = new Map(); + +staticRouter.use(async (req, res, next) => { + if (!req.path.startsWith("/movies/") || !req.path.endsWith(".gif")) { + return next(); + } + + const accept = req.headers.accept || ""; + const supportsWebP = accept.includes("image/webp"); + if (!supportsWebP) { + return next(); + } + + const relativePath = req.path.slice(1); + const gifPath = path.join(PUBLIC_PATH, relativePath); + + if (!fs.existsSync(gifPath)) { + return next(); + } + + const cacheKey = `${relativePath}:webp`; + if (gifCache.has(cacheKey)) { + const cached = gifCache.get(cacheKey)!; + res.set("Content-Type", "image/webp"); + res.set("Cache-Control", "public, max-age=604800, immutable"); + res.set("Vary", "Accept"); + return res.send(cached); + } + + try { + const buffer = await sharp(gifPath, { animated: true }) + .webp({ quality: 50 }) + .toBuffer(); + + gifCache.set(cacheKey, buffer); + + res.set("Content-Type", "image/webp"); + res.set("Cache-Control", "public, max-age=604800, immutable"); + res.set("Vary", "Accept"); + return res.send(buffer); + } catch { + return next(); + } +}); + // SPA 対応のため、ファイルが存在しないときに index.html を返す staticRouter.use(history()); staticRouter.use( serveStatic(UPLOAD_PATH, { - etag: false, - lastModified: false, + maxAge: "7d", + immutable: true, }), ); staticRouter.use( serveStatic(PUBLIC_PATH, { - etag: false, - lastModified: false, + maxAge: "7d", + immutable: true, }), ); staticRouter.use( serveStatic(CLIENT_DIST_PATH, { - etag: false, - lastModified: false, + maxAge: "1y", + immutable: true, }), );