diff --git a/javascript/.env b/javascript/.env new file mode 100644 index 0000000..e69de29 diff --git a/javascript/src/App.tsx b/javascript/src/App.tsx new file mode 100644 index 0000000..696d508 --- /dev/null +++ b/javascript/src/App.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useState } from "react"; +import PostList from "./components/PostList"; +import PostForm from "./components/PostForm"; +import ErrorBanner from "./components/ErrorBanner"; +import Loader from "./components/Loader"; +import { getPosts } from "./api/client"; +import type { Post, ApiError } from "./api/types"; + +const App: React.FC = () => { + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + + const fetchPosts = async () => { + setLoading(true); + setError(null); + try { + const data = await getPosts(); + setPosts(data); + } catch (err) { + const apiError = err as ApiError; + setError(apiError.message || "API unavailable"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchPosts(); + }, []); + + return ( +
+ {error && } +
+

Simple Social Media

+ + {loading ? : } +
+
+ ); +}; + +export default App; diff --git a/javascript/src/api/client.ts b/javascript/src/api/client.ts new file mode 100644 index 0000000..49f2976 --- /dev/null +++ b/javascript/src/api/client.ts @@ -0,0 +1,113 @@ +import type { + Post, + PostCreate, + PostUpdate, + Comment, + CommentCreate, + CommentUpdate, + Like, + LikeCreate, + ApiError, +} from "./types"; + +const BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; + +const handleResponse = async (res: Response): Promise => { + if (!res.ok) { + let error: ApiError = { code: res.status, message: res.statusText }; + try { + error = await res.json(); + } catch (e) { + // ignore JSON parse error, use default error + } + throw error; + } + return res.json(); +}; + +// Posts +export const getPosts = async (): Promise => { + const res = await fetch(`${BASE_URL}/posts`); + return handleResponse(res); +}; + +export const getPost = async (postId: string): Promise => { + const res = await fetch(`${BASE_URL}/posts/${postId}`); + return handleResponse(res); +}; + +export const createPost = async (data: PostCreate): Promise => { + const res = await fetch(`${BASE_URL}/posts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return handleResponse(res); +}; + +export const updatePost = async (postId: string, data: PostUpdate): Promise => { + const res = await fetch(`${BASE_URL}/posts/${postId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return handleResponse(res); +}; + +export const deletePost = async (postId: string): Promise => { + const res = await fetch(`${BASE_URL}/posts/${postId}`, { method: "DELETE" }); + if (!res.ok && res.status !== 204) throw await res.json(); +}; + +// Comments +export const getComments = async (postId: string): Promise => { + const res = await fetch(`${BASE_URL}/posts/${postId}/comments`); + return handleResponse(res); +}; + +export const getComment = async (postId: string, commentId: string): Promise => { + const res = await fetch(`${BASE_URL}/posts/${postId}/comments/${commentId}`); + return handleResponse(res); +}; + +export const createComment = async (postId: string, data: CommentCreate): Promise => { + const res = await fetch(`${BASE_URL}/posts/${postId}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return handleResponse(res); +}; + +export const updateComment = async ( + postId: string, + commentId: string, + data: CommentUpdate +): Promise => { + const res = await fetch(`${BASE_URL}/posts/${postId}/comments/${commentId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return handleResponse(res); +}; + +export const deleteComment = async (postId: string, commentId: string): Promise => { + const res = await fetch(`${BASE_URL}/posts/${postId}/comments/${commentId}`, { method: "DELETE" }); + if (!res.ok && res.status !== 204) throw await res.json(); +}; + +// Likes +export const likePost = async (postId: string, data: LikeCreate): Promise => { + const res = await fetch(`${BASE_URL}/posts/${postId}/likes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + return handleResponse(res); +}; + +export const unlikePost = async (postId: string): Promise => { + const res = await fetch(`${BASE_URL}/posts/${postId}/likes`, { method: "DELETE" }); + if (!res.ok && res.status !== 204) throw await res.json(); +}; diff --git a/javascript/src/api/types.ts b/javascript/src/api/types.ts new file mode 100644 index 0000000..b7e69af --- /dev/null +++ b/javascript/src/api/types.ts @@ -0,0 +1,55 @@ +// Types generated from openapi.yaml + +export type Post = { + id: string; + username: string; + content: string; + createdAt: string; + updatedAt: string; + comments: Comment[]; + likes: number; +}; + +export type PostCreate = { + username: string; + content: string; +}; + +export type PostUpdate = { + username: string; + content: string; +}; + +export type Comment = { + id: string; + postId: string; + username: string; + content: string; + createdAt: string; + updatedAt: string; +}; + +export type CommentCreate = { + username: string; + content: string; +}; + +export type CommentUpdate = { + username: string; + content: string; +}; + +export type Like = { + postId: string; + username: string; + createdAt: string; +}; + +export type LikeCreate = { + username: string; +}; + +export type ApiError = { + code: number; + message: string; +}; diff --git a/javascript/src/components/CommentCard.tsx b/javascript/src/components/CommentCard.tsx new file mode 100644 index 0000000..5c367bd --- /dev/null +++ b/javascript/src/components/CommentCard.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import type { Comment } from "../api/types"; + +type CommentCardProps = { + comment: Comment; +}; + +const CommentCard: React.FC = ({ comment }) => { + return ( +
+
+ {comment.username} + {new Date(comment.createdAt).toLocaleString()} +
+
{comment.content}
+
+ ); +}; + +export default CommentCard; diff --git a/javascript/src/components/CommentForm.tsx b/javascript/src/components/CommentForm.tsx new file mode 100644 index 0000000..25dfcd0 --- /dev/null +++ b/javascript/src/components/CommentForm.tsx @@ -0,0 +1,71 @@ +import React, { useState } from "react"; +import { createComment } from "../api/client"; +import type { CommentCreate, ApiError } from "../api/types"; + +type CommentFormProps = { + postId: string; + onCommentAdded?: () => void; +}; + +const CommentForm: React.FC = ({ postId, onCommentAdded }) => { + const [username, setUsername] = useState(""); + const [content, setContent] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + if (!username.trim() || !content.trim()) { + setError("Username and content are required."); + return; + } + setLoading(true); + try { + const data: CommentCreate = { username, content }; + await createComment(postId, data); + setUsername(""); + setContent(""); + if (onCommentAdded) onCommentAdded(); + } catch (err) { + const apiError = err as ApiError; + setError(apiError.message || "Failed to add comment"); + } finally { + setLoading(false); + } + }; + + return ( +
+ setUsername(e.target.value)} + aria-label="Username" + required + /> +