Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,4 @@ $RECYCLE.BIN/
application/upload/**
!application/upload/**/
!application/upload/**/.gitkeep
cookie.txt
6 changes: 4 additions & 2 deletions application/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"license": "MPL-2.0",
"author": "CyberAgent, Inc.",
"scripts": {
"build": "NODE_ENV=development webpack",
"build": "webpack",
"typecheck": "tsc"
},
"dependencies": {
Expand Down Expand Up @@ -86,7 +86,9 @@
"typescript": "5.9.3",
"webpack": "5.102.1",
"webpack-cli": "6.0.1",
"webpack-dev-server": "5.2.2"
"webpack-dev-server": "5.2.2",
"@tailwindcss/postcss": "^4.2.1",
"tailwindcss": "^4.2.1"
},
"engines": {
"node": "24.14.0"
Expand Down
2 changes: 2 additions & 0 deletions application/client/postcss.config.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
const postcssImport = require("postcss-import");
const tailwindcss = require("@tailwindcss/postcss");
const postcssPresetEnv = require("postcss-preset-env");

module.exports = {
plugins: [
postcssImport(),
tailwindcss(),
postcssPresetEnv({
stage: 3,
}),
Expand Down
21 changes: 17 additions & 4 deletions application/client/src/components/application/NavigationItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,38 @@ interface Props {
commandfor?: string;
}

export const NavigationItem = ({ badge, href, icon, command, commandfor, text }: Props) => {
export const NavigationItem = ({
badge,
href,
icon,
command,
commandfor,
text,
}: Props) => {
const location = useLocation();
const isActive = location.pathname === href;
return (
<li>
{href !== undefined ? (
<Link
aria-label={text}
className={classNames(
"flex flex-col items-center justify-center w-12 h-12 hover:bg-cax-brand-soft rounded-full sm:px-2 sm:w-24 sm:h-auto sm:rounded-sm lg:flex-row lg:justify-start lg:px-4 lg:py-2 lg:w-auto lg:h-auto lg:rounded-full",
{ "text-cax-brand": isActive },
{ "text-cax-brand": isActive }
)}
to={href}
>
<span className="relative text-xl lg:pr-2 lg:text-3xl">
{icon}
{badge}
</span>
<span className="hidden sm:inline sm:text-sm lg:text-xl lg:font-bold">{text}</span>
<span className="hidden sm:inline sm:text-sm lg:text-xl lg:font-bold">
{text}
</span>
</Link>
) : (
<button
aria-label={text}
className="hover:bg-cax-brand-soft flex h-12 w-12 flex-col items-center justify-center rounded-full sm:h-auto sm:w-24 sm:rounded-sm sm:px-2 lg:h-auto lg:w-auto lg:flex-row lg:justify-start lg:rounded-full lg:px-4 lg:py-2"
type="button"
command={command}
Expand All @@ -42,7 +53,9 @@ export const NavigationItem = ({ badge, href, icon, command, commandfor, text }:
{icon}
{badge}
</span>
<span className="hidden sm:inline sm:text-sm lg:text-xl lg:font-bold">{text}</span>
<span className="hidden sm:inline sm:text-sm lg:text-xl lg:font-bold">
{text}
</span>
</button>
)}
</li>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,6 +12,11 @@ interface Props {
newDmModalId: string;
}

const jaDateTimeFormatter = new Intl.DateTimeFormat("ja-JP", {
dateStyle: "short",
timeStyle: "short",
});

export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
const [conversations, setConversations] =
useState<Array<Models.DirectMessageConversation> | null>(null);
Expand All @@ -24,7 +28,8 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
}

try {
const conversations = await fetchJSON<Array<Models.DirectMessageConversation>>("/api/v1/dm");
const conversations =
await fetchJSON<Array<Models.DirectMessageConversation>>("/api/v1/dm");
setConversations(conversations);
setError(null);
} catch (error) {
Expand Down Expand Up @@ -53,15 +58,19 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
<Button
command="show-modal"
commandfor={newDmModalId}
leftItem={<FontAwesomeIcon iconType="paper-plane" styleType="solid" />}
leftItem={
<FontAwesomeIcon iconType="paper-plane" styleType="solid" />
}
>
新しくDMを始める
</Button>
</div>
</header>

{error != null ? (
<p className="text-cax-danger px-4 py-6 text-center text-sm">DMの取得に失敗しました</p>
<p className="text-cax-danger px-4 py-6 text-center text-sm">
DMの取得に失敗しました
</p>
) : conversations.length === 0 ? (
<p className="text-cax-text-muted px-4 py-6 text-center">
まだDMで会話した相手がいません。
Expand All @@ -82,7 +91,10 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {

return (
<li className="grid" key={conversation.id}>
<Link className="hover:bg-cax-surface-subtle px-4" to={`/dm/${conversation.id}`}>
<Link
className="hover:bg-cax-surface-subtle px-4"
to={`/dm/${conversation.id}`}
>
<div className="border-cax-border flex gap-4 border-b px-4 pt-2 pb-4">
<img
alt={peer.profileImage.alt}
Expand All @@ -93,18 +105,24 @@ export const DirectMessageListPage = ({ activeUser, newDmModalId }: Props) => {
<div className="flex items-center justify-between">
<div>
<p className="font-bold">{peer.name}</p>
<p className="text-cax-text-muted text-xs">@{peer.username}</p>
<p className="text-cax-text-muted text-xs">
@{peer.username}
</p>
</div>
{lastMessage != null && (
<time
className="text-cax-text-subtle text-xs"
dateTime={lastMessage.createdAt}
>
{moment(lastMessage.createdAt).locale("ja").fromNow()}
{jaDateTimeFormatter.format(
new Date(lastMessage.createdAt),
)}
</time>
)}
</div>
<p className="mt-1 line-clamp-2 text-sm wrap-anywhere">{lastMessage?.body}</p>
<p className="mt-1 line-clamp-2 text-sm wrap-anywhere">
{lastMessage?.body}
</p>
{hasUnread ? (
<span className="bg-cax-brand-soft text-cax-brand mt-2 inline-flex w-fit rounded-full px-3 py-0.5 text-xs">
未読
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import classNames from "classnames";
import moment from "moment";
import {
ChangeEvent,
useCallback,
Expand All @@ -25,6 +24,12 @@ interface Props {
onSubmit: (params: DirectMessageFormData) => Promise<void>;
}

const jaTimeFormatter = new Intl.DateTimeFormat("ja-JP", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});

export const DirectMessagePage = ({
conversationError,
conversation,
Expand All @@ -38,7 +43,9 @@ export const DirectMessagePage = ({
const textAreaId = useId();

const peer =
conversation.initiator.id !== activeUser.id ? conversation.initiator : conversation.member;
conversation.initiator.id !== activeUser.id
? conversation.initiator
: conversation.member;

const [text, setText] = useState("");
const textAreaRows = Math.min((text || "").split("\n").length, 5);
Expand All @@ -55,7 +62,11 @@ export const DirectMessagePage = ({

const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
formRef.current?.requestSubmit();
}
Expand All @@ -75,7 +86,9 @@ export const DirectMessagePage = ({

useEffect(() => {
const id = setInterval(() => {
const height = Number(window.getComputedStyle(document.body).height.replace("px", ""));
const height = Number(
window.getComputedStyle(document.body).height.replace("px", ""),
);
if (height !== scrollHeightRef.current) {
scrollHeightRef.current = height;
window.scrollTo(0, height);
Expand All @@ -88,7 +101,9 @@ export const DirectMessagePage = ({
if (conversationError != null) {
return (
<section className="px-6 py-10">
<p className="text-cax-danger text-sm">メッセージの取得に失敗しました</p>
<p className="text-cax-danger text-sm">
メッセージの取得に失敗しました
</p>
</section>
);
}
Expand Down Expand Up @@ -141,7 +156,7 @@ export const DirectMessagePage = ({
</p>
<div className="flex gap-1 text-xs">
<time dateTime={message.createdAt}>
{moment(message.createdAt).locale("ja").format("HH:mm")}
{jaTimeFormatter.format(new Date(message.createdAt))}
</time>
{isActiveUserSend && message.isRead && (
<span className="text-cax-text-muted">既読</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
interface Props {
src: string;
alt?: string;
}

export const SimpleCoveredImage = ({ src, alt = "" }: Props) => {
return (
<img
src={src}
alt={alt}
className="h-full w-full object-cover"
decoding="async"
/>
);
};
10 changes: 6 additions & 4 deletions application/client/src/components/post/CommentItem.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import moment from "moment";

import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link";
import { TranslatableText } from "@web-speed-hackathon-2026/client/src/components/post/TranslatableText";
import { getProfileImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path";
Expand All @@ -8,6 +6,10 @@ interface Props {
comment: Models.Comment;
}

const jaLongDateFormatter = new Intl.DateTimeFormat("ja-JP", {
dateStyle: "long",
});

export const CommentItem = ({ comment }: Props) => {
return (
<article className="hover:bg-cax-surface-subtle px-1 sm:px-4">
Expand Down Expand Up @@ -42,8 +44,8 @@ export const CommentItem = ({ comment }: Props) => {
<TranslatableText text={comment.text} />
</div>
<p className="text-cax-text-muted pt-1 text-xs">
<time dateTime={moment(comment.createdAt).toISOString()}>
{moment(comment.createdAt).locale("ja").format("LL")}
<time dateTime={new Date(comment.createdAt).toISOString()}>
{jaLongDateFormatter.format(new Date(comment.createdAt))}
</time>
</p>
</div>
Expand Down
16 changes: 11 additions & 5 deletions application/client/src/components/post/PostItem.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import moment from "moment";

import { Link } from "@web-speed-hackathon-2026/client/src/components/foundation/Link";
import { ImageArea } from "@web-speed-hackathon-2026/client/src/components/post/ImageArea";
import { MovieArea } from "@web-speed-hackathon-2026/client/src/components/post/MovieArea";
Expand All @@ -11,6 +9,10 @@ interface Props {
post: Models.Post;
}

const jaLongDateFormatter = new Intl.DateTimeFormat("ja-JP", {
dateStyle: "long",
});

export const PostItem = ({ post }: Props) => {
return (
<article className="px-1 sm:px-4">
Expand All @@ -24,6 +26,7 @@ export const PostItem = ({ post }: Props) => {
<img
alt={post.user.profileImage.alt}
src={getProfileImagePath(post.user.profileImage.id)}
decoding="async"
/>
</Link>
</div>
Expand Down Expand Up @@ -66,9 +69,12 @@ export const PostItem = ({ post }: Props) => {
</div>
) : null}
<p className="mt-2 text-sm sm:mt-4">
<Link className="text-cax-text-muted hover:underline" to={`/posts/${post.id}`}>
<time dateTime={moment(post.createdAt).toISOString()}>
{moment(post.createdAt).locale("ja").format("LL")}
<Link
className="text-cax-text-muted hover:underline"
to={`/posts/${post.id}`}
>
<time dateTime={new Date(post.createdAt).toISOString()}>
{jaLongDateFormatter.format(new Date(post.createdAt))}
</time>
</Link>
</p>
Expand Down
34 changes: 34 additions & 0 deletions application/client/src/components/post/TimelineImageArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import classNames from "classnames";

import { SimpleCoveredImage } from "@web-speed-hackathon-2026/client/src/components/foundation/SimpleCoveredImage";
import { getImagePath } from "@web-speed-hackathon-2026/client/src/utils/get_path";

interface Props {
images: Models.Image[];
}

export const TimelineImageArea = ({ images }: Props) => {
return (
<div className="relative w-full" style={{ aspectRatio: "16 / 9" }}>
<div className="border-cax-border absolute inset-0 grid h-full w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden rounded-lg border">
{images.map((image, idx) => {
return (
<div
key={image.id}
className={classNames("bg-cax-surface-subtle", {
"col-span-1": images.length !== 1,
"col-span-2": images.length === 1,
"row-span-1":
images.length > 2 && (images.length !== 3 || idx !== 0),
"row-span-2":
images.length <= 2 || (images.length === 3 && idx === 0),
})}
>
<SimpleCoveredImage src={getImagePath(image.id)} />
</div>
);
})}
</div>
</div>
);
};
22 changes: 22 additions & 0 deletions application/client/src/components/post/TimelineMovieArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getMoviePath } from "@web-speed-hackathon-2026/client/src/utils/get_path";

interface Props {
movie: Models.Movie;
}

export const TimelineMovieArea = ({ movie }: Props) => {
return (
<div
className="border-cax-border bg-cax-surface-subtle relative w-full overflow-hidden rounded-lg border"
data-movie-area
style={{ aspectRatio: "1 / 1" }}
>
<img
src={getMoviePath(movie.id)}
alt=""
className="h-full w-full object-cover"
decoding="async"
/>
</div>
);
};
Loading
Loading