diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 06fe5c6..2548246 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -12,6 +12,7 @@
"ghcr.io/devcontainers/features/java:latest": {
"installGradle": true,
"installMaven": true,
+ "mavenVersion": "3.9.10",
"version": "21"
},
"ghcr.io/devcontainers/features/node:latest": {},
diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh
index 7672dda..1165643 100755
--- a/.devcontainer/on-create.sh
+++ b/.devcontainer/on-create.sh
@@ -11,4 +11,7 @@ git config --global core.autocrlf input
echo Install .NET dev certs
dotnet dev-certs https --trust
+echo Install uv for Python
+sudo curl -LsSf https://astral.sh/uv/install.sh | sh
+
echo Done!
diff --git a/.gitignore b/.gitignore
index 8bd2f22..1720c36 100644
--- a/.gitignore
+++ b/.gitignore
@@ -389,6 +389,9 @@ FodyWeavers.xsd
!.vscode/.gitkeep
*.code-workspace
+# GitHub Copilot custom instructions
+.github/copilot-instructions.md
+
# Local History for Visual Studio Code
.history/
diff --git a/README.md b/README.md
index f155e8d..bec39ce 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# GitHub Copilot Vibe Coding Workshop
-
+
Let's vibe-code with [GitHub Copilot](https://docs.github.com/copilot/about-github-copilot/what-is-github-copilot) and its newest and greatest features in various programming languages such as Python, JavaScript, Java and .NET, as well as make the apps cloud-native by containerization. Are you ready to jump in?
@@ -16,9 +16,19 @@ But here's the situation...
- Add custom instruction to GitHub Copilot so that you have more control over GitHub Copilot.
- Add various MCP servers to GitHub Copilot so that you build the applications more precisely.
+## Workshop in Your Language
+
+This workshop material is currently provided in the following languages:
+
+[English](./README.md) | [Español](./localisation/es-es/) | [Français](./localisation/fr-fr/) | [日本語](./localisation/ja-jp/) | [한국어](./localisation/ko-kr/) | [Português](./localisation/pt-br/) | [中文(简体)](./localisation/zh-cn/)
+
## Prerequisites
-During this workshop, [GitHub Codespaces](https://docs.github.com/en/codespaces/about-codespaces/what-are-codespaces) is highly recommended because there's no need for preparation, except a web browser. However, if you really need to use your machine, make sure you've installed everything identified below.
+During this workshop, [GitHub Codespaces](https://docs.github.com/en/codespaces/about-codespaces/what-are-codespaces) is highly recommended because there's no need for preparation, except a web browser.
+
+[](https://codespaces.new/microsoft/github-copilot-vibe-coding-workshop)
+
+However, if you really need to use your machine, make sure you've installed everything identified below.
### Common
@@ -34,6 +44,7 @@ During this workshop, [GitHub Codespaces](https://docs.github.com/en/codespaces/
- [pyenv](https://github.com/pyenv/pyenv) or [pyenv for Windows](https://github.com/pyenv-win/pyenv-win)
- Python 3.12+ through pyenv
+- `uv` package manager (recommended) or `pip`
- VS Code [Python](https://marketplace.visualstudio.com/items/?itemName=ms-python.python) Extension
- VS Code [Pylance](https://marketplace.visualstudio.com/items/?itemName=ms-python.vscode-pylance) Extension
- VS Code [Python Debugger](https://marketplace.visualstudio.com/items/?itemName=ms-python.debugpy) Extension
diff --git a/complete/Dockerfile.dotnet b/complete/Dockerfile.dotnet
index e7d405f..ecb5eba 100644
--- a/complete/Dockerfile.dotnet
+++ b/complete/Dockerfile.dotnet
@@ -3,19 +3,19 @@ FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy the project files
-COPY ["dotnet/ContosoSnsWebApp/ContosoSnsWebApp.csproj", "ContosoSnsWebApp/"]
-RUN dotnet restore "ContosoSnsWebApp/ContosoSnsWebApp.csproj"
+COPY ["dotnet/Contoso.BlazorApp/Contoso.BlazorApp.csproj", "Contoso.BlazorApp/"]
+RUN dotnet restore "Contoso.BlazorApp/Contoso.BlazorApp.csproj"
# Copy the rest of the application code
-COPY ["dotnet/ContosoSnsWebApp/", "ContosoSnsWebApp/"]
+COPY ["dotnet/Contoso.BlazorApp/", "Contoso.BlazorApp/"]
# Build the application
-WORKDIR "/src/ContosoSnsWebApp"
-RUN dotnet build "ContosoSnsWebApp.csproj" -c Release -o /app/build
+WORKDIR "/src/Contoso.BlazorApp"
+RUN dotnet build "Contoso.BlazorApp.csproj" -c Release -o /app/build
# Stage 2: Publish the application
FROM build AS publish
-RUN dotnet publish "ContosoSnsWebApp.csproj" -c Release -o /app/publish /p:UseAppHost=false
+RUN dotnet publish "Contoso.BlazorApp.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Stage 3: Final stage with the runtime image
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
@@ -25,9 +25,10 @@ EXPOSE 8080
# Set the environment variables
ENV ASPNETCORE_URLS=http://+:8080
ENV DOTNET_RUNNING_IN_CONTAINER=true
+ENV ApiSettings__BaseUrl="${ApiSettings__BaseUrl}"
# Copy the published files from the build stage
COPY --from=publish /app/publish .
# Set the entry point for the container
-ENTRYPOINT ["dotnet", "ContosoSnsWebApp.dll"]
\ No newline at end of file
+ENTRYPOINT ["dotnet", "Contoso.BlazorApp.dll"]
diff --git a/complete/Dockerfile.java b/complete/Dockerfile.java
index 9506ffc..06422d9 100644
--- a/complete/Dockerfile.java
+++ b/complete/Dockerfile.java
@@ -1,55 +1,90 @@
-# Stage 1: Build the application
-FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu AS build
+# Multi-stage build for Java Spring Boot application
+# Stage 1: Build stage with Microsoft OpenJDK 21
+FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu AS builder
-WORKDIR /app
+# Set working directory for build
+WORKDIR /workspace
-# Copy gradle files for dependency resolution
-COPY java/demo/gradle/ ./gradle/
-COPY java/demo/gradlew java/demo/build.gradle java/demo/settings.gradle ./
+# Copy Gradle configuration files
+COPY java/socialapp/gradle ./gradle
+COPY java/socialapp/gradlew .
+COPY java/socialapp/gradlew.bat .
+COPY java/socialapp/build.gradle .
+COPY java/socialapp/settings.gradle .
-# Give executable permissions to gradlew
+# Make gradlew executable
RUN chmod +x ./gradlew
-# Download dependencies to cache this layer
+# Download dependencies (for better Docker layer caching)
RUN ./gradlew dependencies --no-daemon
# Copy source code
-COPY java/demo/src ./src
+COPY java/socialapp/src ./src
# Build the application
-RUN ./gradlew bootJar --no-daemon
+RUN ./gradlew build --no-daemon -x test
# Stage 2: Extract JRE from JDK
-FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu AS jre-build
+FROM mcr.microsoft.com/openjdk/jdk:21-ubuntu AS jre-builder
-# Create a custom JRE using jlink that only includes modules needed for the application
+# Create a custom JRE using jlink
RUN jlink \
- --add-modules java.base,java.compiler,java.desktop,java.instrument,java.management,java.naming,java.prefs,java.rmi,java.security.jgss,java.security.sasl,java.sql,jdk.crypto.ec,jdk.unsupported,jdk.zipfs,jdk.management \
+ --add-modules java.base,java.desktop,java.instrument,java.management,java.naming,java.net.http,java.security.jgss,java.sql,jdk.unsupported \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
- --output /jre-minimal
+ --output /custom-jre
-# Stage 3: Create final image
+# Stage 3: Runtime stage with custom JRE
FROM ubuntu:22.04
-# Set environment variables
-ENV JAVA_HOME=/opt/jre-minimal
+# Install required packages and clean up
+RUN apt-get update && \
+ apt-get install -y \
+ ca-certificates \
+ sqlite3 \
+ curl \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy custom JRE from jre-builder stage
+COPY --from=jre-builder /custom-jre /opt/java/openjdk
+
+# Set JAVA_HOME and update PATH
+ENV JAVA_HOME=/opt/java/openjdk
ENV PATH="${JAVA_HOME}/bin:${PATH}"
-# Copy the extracted JRE from the jre-build stage
-COPY --from=jre-build /jre-minimal $JAVA_HOME
+# Create application user for security
+RUN groupadd -r appuser && useradd -r -g appuser appuser
-# Copy the built application from the build stage
+# Set working directory
WORKDIR /app
-COPY --from=build /app/build/libs/*.jar app.jar
-# Create a directory for persistent data
-RUN mkdir -p /app/data
+# Copy the built JAR from builder stage
+COPY --from=builder /workspace/build/libs/*.jar app.jar
+
+# Create SQLite database file with proper permissions
+RUN touch sns_api.db && \
+ chown appuser:appuser sns_api.db && \
+ chmod 664 sns_api.db
-# Expose the application port
+# Change ownership of the app directory
+RUN chown -R appuser:appuser /app
+
+# Switch to non-root user
+USER appuser
+
+# Set environment variables for GitHub Codespaces
+ENV CODESPACE_NAME="${CODESPACE_NAME}"
+ENV GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN="${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}"
+
+# Expose port 8080
EXPOSE 8080
-# Set the entrypoint command to run the application
-ENTRYPOINT ["java", "-jar", "/app/app.jar"]
\ No newline at end of file
+# Health check
+HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
+ CMD curl -f http://localhost:8080/actuator/health || exit 1
+
+# Run the application
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/complete/README.md b/complete/README.md
index 7249752..1be3ce8 100644
--- a/complete/README.md
+++ b/complete/README.md
@@ -17,19 +17,37 @@ Refer to the [README](../README.md) doc for preparation.
### Getting Started
-1. Make sure that Docker Desktop is running.
+1. Make sure that Docker is running.
```bash
docker info
```
+1. Get the repository root.
+
+ ```bash
+ # bash/zsh
+ REPOSITORY_ROOT=$(git rev-parse --show-toplevel)
+ ```
+
+ ```powershell
+ # PowerShell
+ $REPOSITORY_ROOT = git rev-parse --show-toplevel
+ ```
+
+1. Navigate to the `complete` directory.
+
+ ```bash
+ cd $REPOSITORY_ROOT/complete
+ ```
+
1. Run the containerized apps.
```bash
docker compose up --build -d
```
-1. Open a web browser and navigate to `http://localhost:3000`.
+1. Open a web browser and navigate to `http://localhost:3030`.
1. Verify if the web application is running properly.
1. Clean up by running the following command to remove the containerized apps.
diff --git a/complete/compose.yaml b/complete/compose.yaml
index 2cb91bc..c715390 100644
--- a/complete/compose.yaml
+++ b/complete/compose.yaml
@@ -1,3 +1,9 @@
+version: '3.8'
+
+networks:
+ contoso:
+ driver: bridge
+
services:
contoso-backend:
build:
@@ -5,9 +11,18 @@ services:
dockerfile: Dockerfile.java
container_name: contoso-backend
ports:
- - "5050:8080"
+ - "8080:8080"
+ environment:
+ - CODESPACE_NAME=${CODESPACE_NAME}
+ - GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}
networks:
- contoso
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
+ interval: 30s
+ timeout: 3s
+ start_period: 60s
+ retries: 3
contoso-frontend:
build:
@@ -16,13 +31,9 @@ services:
container_name: contoso-frontend
ports:
- "3030:8080"
- depends_on:
- - contoso-backend
environment:
- - ApiBaseUrl=http://contoso-backend:8080
+ - ApiSettings__BaseUrl=http://contoso-backend:8080/api
networks:
- contoso
-
-networks:
- contoso:
- name: contoso
+ depends_on:
+ - contoso-backend
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/App.razor b/complete/dotnet/Contoso.BlazorApp/Components/App.razor
similarity index 70%
rename from complete/dotnet/ContosoSnsWebApp/Components/App.razor
rename to complete/dotnet/Contoso.BlazorApp/Components/App.razor
index 4439202..9ec96cf 100644
--- a/complete/dotnet/ContosoSnsWebApp/Components/App.razor
+++ b/complete/dotnet/Contoso.BlazorApp/Components/App.razor
@@ -5,11 +5,12 @@
-
+
-
+
+
Contoso Outdoor Social
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/CommentIcon.razor b/complete/dotnet/Contoso.BlazorApp/Components/CommentIcon.razor
new file mode 100644
index 0000000..af5c419
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/CommentIcon.razor
@@ -0,0 +1,4 @@
+
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/CommentInput.razor b/complete/dotnet/Contoso.BlazorApp/Components/CommentInput.razor
new file mode 100644
index 0000000..2f3e97f
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/CommentInput.razor
@@ -0,0 +1,90 @@
+@inject AuthService AuthService
+@inject ApiService ApiService
+
+
+
+
+
+
+ @if (!string.IsNullOrEmpty(error))
+ {
+
@error
+ }
+
+
+
+
+
+
+
+
+@code {
+ [Parameter] public string PostId { get; set; } = string.Empty;
+ [Parameter] public EventCallback OnCommentAdded { get; set; }
+
+ private string content = "";
+ private bool isLoading = false;
+ private string error = "";
+
+ private async Task HandleKeyPress(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter" && !e.ShiftKey && !isLoading && !string.IsNullOrWhiteSpace(content))
+ {
+ await HandleSubmit();
+ }
+ }
+
+ private async Task HandleSubmit()
+ {
+ var trimmedContent = content.Trim();
+
+ if (string.IsNullOrEmpty(trimmedContent))
+ {
+ error = "Please enter a comment.";
+ return;
+ }
+
+ isLoading = true;
+ error = "";
+ StateHasChanged();
+
+ try
+ {
+ var user = AuthService.AuthState.User;
+ if (user == null) throw new InvalidOperationException("User not authenticated");
+
+ await ApiService.CreateCommentAsync(PostId, trimmedContent, user.Username);
+ content = "";
+ await OnCommentAdded.InvokeAsync();
+ }
+ catch (Exception ex)
+ {
+ error = "An error occurred while posting your comment.";
+ Console.WriteLine($"Error creating comment: {ex.Message}");
+ }
+ finally
+ {
+ isLoading = false;
+ StateHasChanged();
+ }
+ }
+
+ private void HandleCancel()
+ {
+ content = "";
+ error = "";
+ StateHasChanged();
+ }
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/CommentItem.razor b/complete/dotnet/Contoso.BlazorApp/Components/CommentItem.razor
new file mode 100644
index 0000000..5f149d5
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/CommentItem.razor
@@ -0,0 +1,121 @@
+@using Contoso.BlazorApp.Models
+@using Contoso.BlazorApp.Services
+@inject AuthService AuthService
+@inject ApiService ApiService
+@inject IJSRuntime JSRuntime
+
+
+
+
+
+ @Comment.Username
+
+
+
+
+ @if (isEditing)
+ {
+
+
+
+
+
+
+
+ }
+ else
+ {
+
+
+ @Comment.Content
+
+ @if (isAuthor)
+ {
+
+
+
+
+ }
+
+ }
+
+
+
+@code {
+ [Parameter] public Comment Comment { get; set; } = new();
+ [Parameter] public string PostId { get; set; } = string.Empty;
+ [Parameter] public EventCallback OnCommentDelete { get; set; }
+ [Parameter] public EventCallback OnCommentUpdate { get; set; }
+
+ private bool isEditing = false;
+ private string editContent = string.Empty;
+ private bool isAuthor = false;
+
+ protected override void OnInitialized()
+ {
+ editContent = Comment.Content;
+ isAuthor = AuthService.AuthState.User != null && AuthService.AuthState.User.Username == Comment.Username;
+ }
+
+ private void HandleEditClick()
+ {
+ isEditing = true;
+ editContent = Comment.Content;
+ }
+
+ private void HandleCancelEdit()
+ {
+ isEditing = false;
+ editContent = Comment.Content;
+ }
+
+ private async Task HandleSaveEdit()
+ {
+ if (string.IsNullOrWhiteSpace(editContent) || AuthService.AuthState.User == null) return;
+
+ try
+ {
+ await ApiService.UpdateCommentAsync(PostId, Comment.Id, editContent, AuthService.AuthState.User.Username);
+ Comment.Content = editContent;
+ await OnCommentUpdate.InvokeAsync(Comment);
+ isEditing = false;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error updating comment: {ex.Message}");
+ await JSRuntime.InvokeVoidAsync("alert", "An error occurred while updating the comment.");
+ }
+ }
+
+ private async Task HandleDeleteClick()
+ {
+ var confirmed = await JSRuntime.InvokeAsync("confirm", "Are you sure you want to delete this comment?");
+ if (!confirmed) return;
+
+ try
+ {
+ await ApiService.DeleteCommentAsync(PostId, Comment.Id);
+ await OnCommentDelete.InvokeAsync(Comment.Id);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error deleting comment: {ex.Message}");
+ await JSRuntime.InvokeVoidAsync("alert", "An error occurred while deleting the comment.");
+ }
+ }
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/FloatingActionButton.razor b/complete/dotnet/Contoso.BlazorApp/Components/FloatingActionButton.razor
new file mode 100644
index 0000000..d128213
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/FloatingActionButton.razor
@@ -0,0 +1,10 @@
+
+
+@code {
+ [Parameter] public EventCallback OnClick { get; set; }
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/HeartIcon.razor b/complete/dotnet/Contoso.BlazorApp/Components/HeartIcon.razor
new file mode 100644
index 0000000..916dce7
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/HeartIcon.razor
@@ -0,0 +1,10 @@
+
+
+@code {
+ [Parameter] public bool Filled { get; set; }
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/Layout/MainLayout.razor b/complete/dotnet/Contoso.BlazorApp/Components/Layout/MainLayout.razor
new file mode 100644
index 0000000..526f205
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/Layout/MainLayout.razor
@@ -0,0 +1,14 @@
+@inherits LayoutComponentBase
+
+
+
+
+ @Body
+
+
+
+
+ An unhandled error has occurred.
+
Reload
+
🗙
+
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/Layout/MainLayout.razor.css b/complete/dotnet/Contoso.BlazorApp/Components/Layout/MainLayout.razor.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/Components/Layout/MainLayout.razor.css
rename to complete/dotnet/Contoso.BlazorApp/Components/Layout/MainLayout.razor.css
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/Layout/NavMenu.razor b/complete/dotnet/Contoso.BlazorApp/Components/Layout/NavMenu.razor
new file mode 100644
index 0000000..8e2d5ce
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/Layout/NavMenu.razor
@@ -0,0 +1,50 @@
+@inject NavigationManager Navigation
+
+
+
+@code {
+ [Inject] private AuthService authService { get; set; } = default!;
+
+ private async Task HandleLogout()
+ {
+ await authService.LogoutAsync();
+ Navigation.NavigateTo("/");
+ }
+}
+
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/Layout/NavMenu.razor.css b/complete/dotnet/Contoso.BlazorApp/Components/Layout/NavMenu.razor.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/Components/Layout/NavMenu.razor.css
rename to complete/dotnet/Contoso.BlazorApp/Components/Layout/NavMenu.razor.css
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/Modal.razor b/complete/dotnet/Contoso.BlazorApp/Components/Modal.razor
new file mode 100644
index 0000000..4153a1d
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/Modal.razor
@@ -0,0 +1,19 @@
+@if (IsOpen)
+{
+
+}
+
+@code {
+ [Parameter] public bool IsOpen { get; set; }
+ [Parameter] public EventCallback OnClose { get; set; }
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+
+ private async Task HandleBackdropClick()
+ {
+ await OnClose.InvokeAsync();
+ }
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/NameInputModal.razor b/complete/dotnet/Contoso.BlazorApp/Components/NameInputModal.razor
new file mode 100644
index 0000000..d770b07
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/NameInputModal.razor
@@ -0,0 +1,85 @@
+@inject AuthService AuthService
+
+
+
+
Welcome to Contoso Outdoor Social
+
Please enter your name to continue
+
+
+
+
+
+
+ @if (!string.IsNullOrEmpty(error))
+ {
+ @error
+ }
+
+
+
+
+
+
+@code {
+ [Parameter] public bool IsOpen { get; set; }
+ [Parameter] public EventCallback OnClose { get; set; }
+
+ private string username = "";
+ private bool isLoading = false;
+ private string error = "";
+
+ private async Task HandleKeyPress(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(username) && !isLoading)
+ {
+ await HandleSubmit();
+ }
+ }
+
+ private async Task HandleSubmit()
+ {
+ var trimmedUsername = username.Trim();
+
+ if (string.IsNullOrEmpty(trimmedUsername))
+ {
+ error = "Please enter your name.";
+ return;
+ }
+
+ if (trimmedUsername.Length < 2)
+ {
+ error = "Name must be at least 2 characters long.";
+ return;
+ }
+
+ isLoading = true;
+ error = "";
+ StateHasChanged();
+
+ try
+ {
+ await AuthService.LoginAsync(trimmedUsername);
+ username = "";
+ await OnClose.InvokeAsync();
+ }
+ catch (Exception ex)
+ {
+ error = "An error occurred. Please try again.";
+ Console.WriteLine($"Error during login: {ex.Message}");
+ }
+ finally
+ {
+ isLoading = false;
+ StateHasChanged();
+ }
+ }
+}
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/Pages/Counter.razor b/complete/dotnet/Contoso.BlazorApp/Components/Pages/Counter.razor
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/Components/Pages/Counter.razor
rename to complete/dotnet/Contoso.BlazorApp/Components/Pages/Counter.razor
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/Pages/Error.razor b/complete/dotnet/Contoso.BlazorApp/Components/Pages/Error.razor
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/Components/Pages/Error.razor
rename to complete/dotnet/Contoso.BlazorApp/Components/Pages/Error.razor
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/Pages/Home.razor b/complete/dotnet/Contoso.BlazorApp/Components/Pages/Home.razor
new file mode 100644
index 0000000..04f4fcb
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/Pages/Home.razor
@@ -0,0 +1,137 @@
+@page "/"
+@rendermode InteractiveServer
+@inject AuthService AuthService
+@inject ApiService ApiService
+@inject IJSRuntime JSRuntime
+@implements IDisposable
+
+Contoso Outdoor Social
+
+
+
Contoso Outdoor Social
+
+ @if (isLoading)
+ {
+
Loading posts...
+ }
+ else if (!string.IsNullOrEmpty(error))
+ {
+
@error
+ }
+ else if (posts.Count == 0)
+ {
+
No posts yet.
+ }
+ else
+ {
+
+ @foreach (var post in posts)
+ {
+
+ }
+
+ }
+
+
+
+
+
+
+@code {
+ private List posts = new();
+ private bool isLoading = true;
+ private string error = "";
+ private bool isPostModalOpen = false;
+ private bool isNameModalOpen = false;
+
+ protected override void OnInitialized()
+ {
+ AuthService.OnAuthStateChanged += HandleAuthStateChanged;
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ await AuthService.InitializeAsync();
+
+ if (!AuthService.AuthState.IsLoading && !AuthService.AuthState.IsAuthenticated)
+ {
+ isNameModalOpen = true;
+ }
+ else if (AuthService.AuthState.IsAuthenticated)
+ {
+ await FetchPosts();
+ }
+
+ StateHasChanged();
+ }
+ }
+
+ private void HandleAuthStateChanged()
+ {
+ InvokeAsync(async () =>
+ {
+ if (!AuthService.AuthState.IsLoading && !AuthService.AuthState.IsAuthenticated)
+ {
+ isNameModalOpen = true;
+ }
+ else if (AuthService.AuthState.IsAuthenticated)
+ {
+ await FetchPosts();
+ }
+ StateHasChanged();
+ });
+ }
+
+ private async Task FetchPosts()
+ {
+ if (!AuthService.AuthState.IsAuthenticated) return;
+
+ try
+ {
+ isLoading = true;
+ error = "";
+ posts = await ApiService.GetPostsAsync();
+ }
+ catch (Exception ex)
+ {
+ error = "An error occurred while loading posts.";
+ Console.WriteLine($"Error fetching posts: {ex.Message}");
+ }
+ finally
+ {
+ isLoading = false;
+ StateHasChanged();
+ }
+ }
+
+ private void HandleOpenPostModal() => isPostModalOpen = true;
+ private void HandleClosePostModal() => isPostModalOpen = false;
+
+ private async Task HandlePostCreated()
+ {
+ await FetchPosts();
+ isPostModalOpen = false;
+ }
+
+ private async Task HandlePostDeleted()
+ {
+ await FetchPosts();
+ }
+
+ private async Task HandlePostUpdated()
+ {
+ await FetchPosts();
+ }
+
+ private void HandleNameModalClose()
+ {
+ isNameModalOpen = false;
+ }
+
+ public void Dispose()
+ {
+ AuthService.OnAuthStateChanged -= HandleAuthStateChanged;
+ }
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/Pages/PostDetail.razor b/complete/dotnet/Contoso.BlazorApp/Components/Pages/PostDetail.razor
new file mode 100644
index 0000000..f2b369e
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/Pages/PostDetail.razor
@@ -0,0 +1,154 @@
+@page "/post/{PostId}"
+@rendermode InteractiveServer
+@inject AuthService AuthService
+@inject ApiService ApiService
+@inject NavigationManager Navigation
+
+Post Details - Contoso Outdoor Social
+
+
+ @if (isLoading)
+ {
+
Loading post...
+ }
+ else if (!string.IsNullOrEmpty(error))
+ {
+
@error
+ }
+ else if (post != null)
+ {
+
+
+
+
+
+
+
+ @if (comments.Count > 0)
+ {
+ @comments.Count
+ }
+
+
+
+
+
+
+
+
+
+ @foreach (var comment in comments)
+ {
+
+ }
+
+ }
+
+
+@code {
+ [Parameter] public string PostId { get; set; } = string.Empty;
+
+ private Post? post;
+ private List comments = new();
+ private bool isLoading = true;
+ private string error = "";
+ private bool isLiked;
+ private int likesCount;
+
+ protected override async Task OnInitializedAsync()
+ {
+ await LoadPostAndComments();
+ }
+
+ private async Task LoadPostAndComments()
+ {
+ try
+ {
+ isLoading = true;
+ error = "";
+
+ post = await ApiService.GetPostAsync(PostId);
+ comments = await ApiService.GetCommentsAsync(PostId);
+
+ isLiked = post.IsLiked;
+ likesCount = post.LikesCount;
+ }
+ catch (Exception ex)
+ {
+ error = "An error occurred while loading the post.";
+ Console.WriteLine($"Error loading post: {ex.Message}");
+ }
+ finally
+ {
+ isLoading = false;
+ StateHasChanged();
+ }
+ }
+
+ private async Task HandleLikeToggle()
+ {
+ try
+ {
+ var user = AuthService.AuthState.User;
+ if (user == null) return;
+
+ if (isLiked)
+ {
+ await ApiService.UnlikePostAsync(PostId, user.Username);
+ likesCount--;
+ isLiked = false;
+ }
+ else
+ {
+ var result = await ApiService.LikePostAsync(PostId, user.Username);
+ likesCount = result.LikesCount;
+ isLiked = true;
+ }
+ StateHasChanged();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error occurred while processing like: {ex.Message}");
+ }
+ }
+
+ private async Task HandleCommentAdded()
+ {
+ await LoadPostAndComments();
+ }
+
+ private Task HandleCommentDelete(string commentId)
+ {
+ comments.RemoveAll(c => c.Id == commentId);
+ if (post != null)
+ {
+ post.CommentsCount = Math.Max(post.CommentsCount - 1, 0);
+ }
+ StateHasChanged();
+ return Task.CompletedTask;
+ }
+
+ private Task HandleCommentUpdate(Comment updatedComment)
+ {
+ var index = comments.FindIndex(c => c.Id == updatedComment.Id);
+ if (index >= 0)
+ {
+ comments[index] = updatedComment;
+ StateHasChanged();
+ }
+ return Task.CompletedTask;
+ }
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/Pages/Profile.razor b/complete/dotnet/Contoso.BlazorApp/Components/Pages/Profile.razor
new file mode 100644
index 0000000..89fda07
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/Pages/Profile.razor
@@ -0,0 +1,174 @@
+@page "/profile"
+@page "/profile/{username}"
+@using Contoso.BlazorApp.Models
+@using Contoso.BlazorApp.Services
+@inject AuthService AuthService
+@inject ApiService ApiService
+@inject NavigationManager Navigation
+@inject IJSRuntime JSRuntime
+
+@(isMyProfile ? "My Profile" : $"{Username}'s Profile") - Vibe
+
+
+ @if (isLoading)
+ {
+
Loading profile...
+ }
+ else
+ {
+
+
+
+
+
+ @displayUsername
+
+
+ @userPosts.Count posts
+
+
+
+
+ @if (isMyProfile)
+ {
+
+ }
+
+
+ @if (!string.IsNullOrEmpty(error))
+ {
+
@error
+ }
+
+
+
+ @(isMyProfile ? "My Posts" : $"{displayUsername}'s Posts")
+
+
+ @if (userPosts.Any())
+ {
+
+ @foreach (var post in userPosts)
+ {
+
+ }
+
+ }
+ else
+ {
+
+ @(isMyProfile ? "You haven't created any posts yet." : "No posts yet.")
+
+ }
+
+
+
+
+ }
+
+
+@code {
+ [Parameter] public string? Username { get; set; }
+
+ private List userPosts = new();
+ private bool isLoading = false;
+ private string error = string.Empty;
+ private bool isMyProfile = false;
+ private string displayUsername = string.Empty;
+ private bool isPostModalOpen = false;
+
+ protected override async Task OnInitializedAsync()
+ {
+ if (!AuthService.AuthState.IsAuthenticated)
+ {
+ Navigation.NavigateTo("/");
+ return;
+ }
+
+ await LoadProfile();
+ }
+
+ protected override async Task OnParametersSetAsync()
+ {
+ if (AuthService.AuthState.IsAuthenticated)
+ {
+ await LoadProfile();
+ }
+ }
+
+ private async Task LoadProfile()
+ {
+ try
+ {
+ isLoading = true;
+ error = string.Empty;
+
+ // Determine which profile to load
+ var targetUsername = string.IsNullOrEmpty(Username) ? AuthService.AuthState.User?.Username : Username;
+ isMyProfile = targetUsername == AuthService.AuthState.User?.Username;
+ displayUsername = targetUsername ?? string.Empty;
+
+ if (string.IsNullOrEmpty(targetUsername))
+ {
+ error = "Invalid user profile.";
+ return;
+ }
+
+ // Load user's posts
+ var posts = await ApiService.GetPostsAsync();
+ userPosts = posts.Where(p => p.Username == targetUsername).OrderByDescending(p => p.Id).ToList();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error loading profile: {ex.Message}");
+ error = "An error occurred while loading the profile.";
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ private async Task HandleLogout()
+ {
+ var confirmed = await JSRuntime.InvokeAsync("confirm", "Are you sure you want to logout?");
+ if (!confirmed) return;
+
+ try
+ {
+ await AuthService.LogoutAsync();
+ Navigation.NavigateTo("/");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error during logout: {ex.Message}");
+ error = "An error occurred during logout.";
+ }
+ }
+
+ private void HandleOpenPostModal()
+ {
+ if (isMyProfile)
+ {
+ isPostModalOpen = true;
+ }
+ }
+
+ private void HandleClosePostModal()
+ {
+ isPostModalOpen = false;
+ }
+
+ private Task HandlePostCreated(Post newPost)
+ {
+ if (isMyProfile)
+ {
+ userPosts.Insert(0, newPost);
+ StateHasChanged();
+ }
+ return Task.CompletedTask;
+ }
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/Pages/Search.razor b/complete/dotnet/Contoso.BlazorApp/Components/Pages/Search.razor
new file mode 100644
index 0000000..0d91007
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/Pages/Search.razor
@@ -0,0 +1,137 @@
+@page "/search"
+@using Contoso.BlazorApp.Models
+@using Contoso.BlazorApp.Services
+@inject AuthService AuthService
+@inject ApiService ApiService
+@inject NavigationManager Navigation
+
+Search - Vibe
+
+
+
+
+
+
+
+ @if (!string.IsNullOrEmpty(error))
+ {
+
@error
+ }
+
+ @if (hasSearched)
+ {
+ @if (searchResults.Any())
+ {
+
+
+ Search Results (@searchResults.Count)
+
+ @foreach (var post in searchResults)
+ {
+
+ }
+
+ }
+ else if (!isLoading)
+ {
+
+ No posts found for "@lastSearchQuery".
+
+ }
+ }
+ else
+ {
+
+ Enter keywords to search for posts.
+
+ }
+
+
+
+
+
+@code {
+ private string searchQuery = string.Empty;
+ private List searchResults = new();
+ private bool isLoading = false;
+ private string error = string.Empty;
+ private bool hasSearched = false;
+ private string lastSearchQuery = string.Empty;
+ private bool isPostModalOpen = false;
+
+ protected override async Task OnInitializedAsync()
+ {
+ if (!AuthService.AuthState.IsAuthenticated)
+ {
+ Navigation.NavigateTo("/");
+ return;
+ }
+
+ await Task.CompletedTask;
+ }
+
+ private async Task HandleSearch()
+ {
+ if (string.IsNullOrWhiteSpace(searchQuery)) return;
+
+ try
+ {
+ isLoading = true;
+ error = string.Empty;
+ lastSearchQuery = searchQuery.Trim();
+
+ var result = await ApiService.SearchPostsAsync(lastSearchQuery);
+ searchResults = result;
+ hasSearched = true;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error searching posts: {ex.Message}");
+ error = "An error occurred while searching. Please try again.";
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ private async Task HandleKeyPress(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter" && !isLoading && !string.IsNullOrWhiteSpace(searchQuery))
+ {
+ await HandleSearch();
+ }
+ }
+
+ private void HandleOpenPostModal()
+ {
+ isPostModalOpen = true;
+ }
+
+ private void HandleClosePostModal()
+ {
+ isPostModalOpen = false;
+ }
+
+ private Task HandlePostCreated(Post newPost)
+ {
+ // Optionally refresh search results if they match the new post
+ StateHasChanged();
+ return Task.CompletedTask;
+ }
+}
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/Pages/Weather.razor b/complete/dotnet/Contoso.BlazorApp/Components/Pages/Weather.razor
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/Components/Pages/Weather.razor
rename to complete/dotnet/Contoso.BlazorApp/Components/Pages/Weather.razor
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/PostCard.razor b/complete/dotnet/Contoso.BlazorApp/Components/PostCard.razor
new file mode 100644
index 0000000..a14ca97
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/PostCard.razor
@@ -0,0 +1,85 @@
+@inject NavigationManager Navigation
+@inject ApiService ApiService
+@inject AuthService AuthService
+
+
+
+
+
+
+
+
+
+
+@code {
+ [Parameter] public Post Post { get; set; } = new();
+ [Parameter] public EventCallback OnPostDeleted { get; set; }
+ [Parameter] public EventCallback OnPostUpdated { get; set; }
+
+ private bool isLiked;
+ private int likesCount;
+
+ protected override void OnInitialized()
+ {
+ isLiked = Post.IsLiked;
+ likesCount = Post.LikesCount;
+ }
+
+ private void HandlePostClick()
+ {
+ Navigation.NavigateTo($"/post/{Post.Id}");
+ }
+
+ private async Task HandleLikeToggle()
+ {
+ try
+ {
+ var user = AuthService.AuthState.User;
+ if (user == null) return;
+
+ if (isLiked)
+ {
+ await ApiService.UnlikePostAsync(Post.Id, user.Username);
+ likesCount--;
+ isLiked = false;
+ }
+ else
+ {
+ var result = await ApiService.LikePostAsync(Post.Id, user.Username);
+ likesCount = result.LikesCount;
+ isLiked = true;
+ }
+ StateHasChanged();
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error occurred while processing like: {ex.Message}");
+ }
+ }
+
+ private void HandleCommentClick()
+ {
+ Navigation.NavigateTo($"/post/{Post.Id}");
+ }
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Components/PostingModal.razor b/complete/dotnet/Contoso.BlazorApp/Components/PostingModal.razor
new file mode 100644
index 0000000..c47f4fb
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Components/PostingModal.razor
@@ -0,0 +1,94 @@
+@inject AuthService AuthService
+@inject ApiService ApiService
+@inject IJSRuntime JSRuntime
+
+
+
+
+
+ @if (!string.IsNullOrEmpty(error))
+ {
+ @error
+ }
+
+
+
+
+
+
+@code {
+ [Parameter] public bool IsOpen { get; set; }
+ [Parameter] public EventCallback OnClose { get; set; }
+ [Parameter] public EventCallback OnPostCreated { get; set; }
+
+ private string content = "";
+ private bool isLoading = false;
+ private string error = "";
+
+ private void HandleContentChange(ChangeEventArgs e)
+ {
+ content = e.Value?.ToString() ?? "";
+ if (!string.IsNullOrEmpty(error))
+ error = "";
+ StateHasChanged();
+ }
+
+ private async Task HandleSubmit()
+ {
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ error = "Please enter content.";
+ return;
+ }
+
+ isLoading = true;
+ error = "";
+ StateHasChanged();
+
+ try
+ {
+ var user = AuthService.AuthState.User;
+ if (user == null) throw new InvalidOperationException("User not authenticated");
+
+ var createdPost = await ApiService.CreatePostAsync(content, user.Username);
+ content = "";
+ await OnClose.InvokeAsync();
+ await OnPostCreated.InvokeAsync(createdPost);
+ }
+ catch (Exception ex)
+ {
+ error = "An error occurred while creating the post. Please try again.";
+ Console.WriteLine($"Error creating post: {ex.Message}");
+ }
+ finally
+ {
+ isLoading = false;
+ StateHasChanged();
+ }
+ }
+
+ private async Task HandleCancel()
+ {
+ if (!string.IsNullOrWhiteSpace(content))
+ {
+ var shouldCancel = await JSRuntime.InvokeAsync("confirm", "You have unsaved content. Are you sure you want to cancel?");
+ if (!shouldCancel) return;
+ }
+
+ content = "";
+ error = "";
+ await OnClose.InvokeAsync();
+ }
+}
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/Routes.razor b/complete/dotnet/Contoso.BlazorApp/Components/Routes.razor
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/Components/Routes.razor
rename to complete/dotnet/Contoso.BlazorApp/Components/Routes.razor
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/_Imports.razor b/complete/dotnet/Contoso.BlazorApp/Components/_Imports.razor
similarity index 72%
rename from complete/dotnet/ContosoSnsWebApp/Components/_Imports.razor
rename to complete/dotnet/Contoso.BlazorApp/Components/_Imports.razor
index 36343d0..b7eb373 100644
--- a/complete/dotnet/ContosoSnsWebApp/Components/_Imports.razor
+++ b/complete/dotnet/Contoso.BlazorApp/Components/_Imports.razor
@@ -6,5 +6,7 @@
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
-@using ContosoSnsWebApp
-@using ContosoSnsWebApp.Components
+@using Contoso.BlazorApp
+@using Contoso.BlazorApp.Components
+@using Contoso.BlazorApp.Models
+@using Contoso.BlazorApp.Services
diff --git a/complete/dotnet/ContosoSnsWebApp/ContosoSnsWebApp.csproj b/complete/dotnet/Contoso.BlazorApp/Contoso.BlazorApp.csproj
similarity index 67%
rename from complete/dotnet/ContosoSnsWebApp/ContosoSnsWebApp.csproj
rename to complete/dotnet/Contoso.BlazorApp/Contoso.BlazorApp.csproj
index 3bce3da..6568b3d 100644
--- a/complete/dotnet/ContosoSnsWebApp/ContosoSnsWebApp.csproj
+++ b/complete/dotnet/Contoso.BlazorApp/Contoso.BlazorApp.csproj
@@ -6,8 +6,4 @@
enable
-
-
-
-
diff --git a/complete/dotnet/Contoso.BlazorApp/Models/ApiSettings.cs b/complete/dotnet/Contoso.BlazorApp/Models/ApiSettings.cs
new file mode 100644
index 0000000..d0271ad
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Models/ApiSettings.cs
@@ -0,0 +1,8 @@
+namespace Contoso.BlazorApp.Models;
+
+public class ApiSettings
+{
+ public const string SectionName = "ApiSettings";
+
+ public string BaseUrl { get; set; } = string.Empty;
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Models/Comment.cs b/complete/dotnet/Contoso.BlazorApp/Models/Comment.cs
new file mode 100644
index 0000000..df65203
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Models/Comment.cs
@@ -0,0 +1,35 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Contoso.BlazorApp.Models;
+
+public class Comment
+{
+ public string Id { get; set; } = string.Empty;
+ public string PostId { get; set; } = string.Empty;
+ public string Username { get; set; } = string.Empty;
+ public string Content { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+ public DateTime UpdatedAt { get; set; }
+}
+
+public class CreateCommentRequest
+{
+ [Required]
+ [StringLength(50, MinimumLength = 1)]
+ public string Username { get; set; } = string.Empty;
+
+ [Required]
+ [StringLength(1000, MinimumLength = 1)]
+ public string Content { get; set; } = string.Empty;
+}
+
+public class UpdateCommentRequest
+{
+ [Required]
+ [StringLength(50, MinimumLength = 1)]
+ public string Username { get; set; } = string.Empty;
+
+ [Required]
+ [StringLength(1000, MinimumLength = 1)]
+ public string Content { get; set; } = string.Empty;
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Models/Post.cs b/complete/dotnet/Contoso.BlazorApp/Models/Post.cs
new file mode 100644
index 0000000..f90ba96
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Models/Post.cs
@@ -0,0 +1,58 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Contoso.BlazorApp.Models;
+
+public class Post
+{
+ public string Id { get; set; } = string.Empty;
+ public string Username { get; set; } = string.Empty;
+ public string Content { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+ public DateTime UpdatedAt { get; set; }
+ public int LikesCount { get; set; }
+ public bool IsLiked { get; set; } // Client-side computed property
+ public int CommentsCount { get; set; }
+}
+
+public class CreatePostRequest
+{
+ [Required]
+ [StringLength(50, MinimumLength = 1)]
+ public string Username { get; set; } = string.Empty;
+
+ [Required]
+ [StringLength(2000, MinimumLength = 1)]
+ public string Content { get; set; } = string.Empty;
+}
+
+public class UpdatePostRequest
+{
+ [Required]
+ [StringLength(50, MinimumLength = 1)]
+ public string Username { get; set; } = string.Empty;
+
+ [Required]
+ [StringLength(2000, MinimumLength = 1)]
+ public string Content { get; set; } = string.Empty;
+}
+
+public class LikeRequest
+{
+ [Required]
+ [StringLength(50, MinimumLength = 1)]
+ public string Username { get; set; } = string.Empty;
+}
+
+public class LikeResponse
+{
+ public string PostId { get; set; } = string.Empty;
+ public string Username { get; set; } = string.Empty;
+ public DateTime LikedAt { get; set; }
+}
+
+public class Error
+{
+ public string ErrorCode { get; set; } = string.Empty;
+ public string Message { get; set; } = string.Empty;
+ public List? Details { get; set; }
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Models/User.cs b/complete/dotnet/Contoso.BlazorApp/Models/User.cs
new file mode 100644
index 0000000..25e1c6f
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Models/User.cs
@@ -0,0 +1,14 @@
+namespace Contoso.BlazorApp.Models;
+
+public class User
+{
+ public string Username { get; set; } = string.Empty;
+ public int? UserId { get; set; }
+}
+
+public class AuthState
+{
+ public User? User { get; set; }
+ public bool IsAuthenticated => User != null;
+ public bool IsLoading { get; set; } = true;
+}
diff --git a/complete/dotnet/ContosoSnsWebApp/Program.cs b/complete/dotnet/Contoso.BlazorApp/Program.cs
similarity index 61%
rename from complete/dotnet/ContosoSnsWebApp/Program.cs
rename to complete/dotnet/Contoso.BlazorApp/Program.cs
index 339f590..abc58c2 100644
--- a/complete/dotnet/ContosoSnsWebApp/Program.cs
+++ b/complete/dotnet/Contoso.BlazorApp/Program.cs
@@ -1,5 +1,6 @@
-using ContosoSnsWebApp.Components;
-using ContosoSnsWebApp.Services; // Add this using directive
+using Contoso.BlazorApp.Components;
+using Contoso.BlazorApp.Services;
+using Contoso.BlazorApp.Models;
var builder = WebApplication.CreateBuilder(args);
@@ -7,8 +8,16 @@
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
-// Register HttpClient and ApiService
-builder.Services.AddHttpClient(); // Register ApiService with HttpClient
+// Configure API settings
+builder.Services.Configure(
+ builder.Configuration.GetSection(ApiSettings.SectionName));
+
+// Add HttpClient for API calls
+builder.Services.AddHttpClient();
+
+// Add custom services
+builder.Services.AddScoped();
+builder.Services.AddScoped();
var app = builder.Build();
diff --git a/complete/dotnet/ContosoSnsWebApp/Properties/launchSettings.json b/complete/dotnet/Contoso.BlazorApp/Properties/launchSettings.json
similarity index 75%
rename from complete/dotnet/ContosoSnsWebApp/Properties/launchSettings.json
rename to complete/dotnet/Contoso.BlazorApp/Properties/launchSettings.json
index 6535c6d..b16eb7a 100644
--- a/complete/dotnet/ContosoSnsWebApp/Properties/launchSettings.json
+++ b/complete/dotnet/Contoso.BlazorApp/Properties/launchSettings.json
@@ -1,11 +1,10 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
- "profiles": {
- "http": {
+ "profiles": { "http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
- "applicationUrl": "http://localhost:5090",
+ "applicationUrl": "http://localhost:3031",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -14,7 +13,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
- "applicationUrl": "https://localhost:7198;http://localhost:5090",
+ "applicationUrl": "https://localhost:43031;http://localhost:3031",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
diff --git a/complete/dotnet/Contoso.BlazorApp/Services/ApiService.cs b/complete/dotnet/Contoso.BlazorApp/Services/ApiService.cs
new file mode 100644
index 0000000..10c315f
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Services/ApiService.cs
@@ -0,0 +1,210 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using Contoso.BlazorApp.Models;
+using Microsoft.Extensions.Options;
+
+namespace Contoso.BlazorApp.Services;
+
+public class ApiService
+{
+ private readonly HttpClient _httpClient;
+ private readonly AuthService _authService;
+ private readonly ILogger _logger;
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true
+ };
+
+ public ApiService(HttpClient httpClient, AuthService authService, IOptions apiSettings, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _authService = authService;
+ _logger = logger;
+
+ var baseUrl = GetApiBaseUrl(apiSettings.Value.BaseUrl);
+ _logger.LogInformation("API Base URL configured to: {BaseUrl}", baseUrl);
+
+ _httpClient.BaseAddress = new Uri(baseUrl);
+ _httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
+ }
+
+ private string GetApiBaseUrl(string configuredBaseUrl)
+ {
+ // Check if running in GitHub Codespaces
+ var codespaceUrl = Environment.GetEnvironmentVariable("CODESPACE_NAME");
+ var githubCodespacesPortForwardingDomain = Environment.GetEnvironmentVariable("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN");
+
+ _logger.LogDebug("CODESPACE_NAME: {CodespaceName}", codespaceUrl ?? "null");
+ _logger.LogDebug("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN: {Domain}", githubCodespacesPortForwardingDomain ?? "null");
+
+ if (!string.IsNullOrEmpty(codespaceUrl) && !string.IsNullOrEmpty(githubCodespacesPortForwardingDomain))
+ {
+ // Construct GitHub Codespaces URL: https://{codespace-name}-8080.{domain}/api/
+ var codespacesUrl = $"https://{codespaceUrl}-8080.{githubCodespacesPortForwardingDomain}/api/";
+ _logger.LogInformation("Using GitHub Codespaces URL: {CodespacesUrl}", codespacesUrl);
+ return codespacesUrl;
+ }
+
+ // Fall back to configured base URL (localhost)
+ // Ensure the URL ends with a forward slash for proper relative path resolution
+ var normalizedUrl = configuredBaseUrl.TrimEnd('/') + "/";
+ _logger.LogInformation("Using configured base URL: {ConfiguredUrl}", normalizedUrl);
+ return normalizedUrl;
+ }
+
+ private void SetAuthHeaders()
+ {
+ var user = _authService.AuthState.User;
+ if (user != null)
+ {
+ if (user.UserId.HasValue)
+ {
+ _httpClient.DefaultRequestHeaders.Remove("X-User-ID");
+ _httpClient.DefaultRequestHeaders.Add("X-User-ID", user.UserId.Value.ToString());
+ }
+ if (!string.IsNullOrEmpty(user.Username))
+ {
+ _httpClient.DefaultRequestHeaders.Remove("x-username");
+ _httpClient.DefaultRequestHeaders.Add("x-username", Uri.EscapeDataString(user.Username));
+ }
+ }
+ }
+
+ // Post API methods
+ public async Task> GetPostsAsync()
+ {
+ SetAuthHeaders();
+ var response = await _httpClient.GetAsync("posts");
+ response.EnsureSuccessStatusCode();
+ var json = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize>(json, JsonOptions) ?? new List();
+ }
+
+ public async Task GetPostAsync(string postId)
+ {
+ SetAuthHeaders();
+ var response = await _httpClient.GetAsync($"posts/{postId}");
+ response.EnsureSuccessStatusCode();
+ var json = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(json, JsonOptions)!;
+ }
+
+ public async Task CreatePostAsync(string content, string username)
+ {
+ SetAuthHeaders();
+ var request = new CreatePostRequest { Content = content, Username = username };
+ var json = JsonSerializer.Serialize(request, JsonOptions);
+ var content_ = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await _httpClient.PostAsync("posts", content_);
+ response.EnsureSuccessStatusCode();
+ var responseJson = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(responseJson, JsonOptions)!;
+ }
+
+ public async Task UpdatePostAsync(string postId, string content, string username)
+ {
+ SetAuthHeaders();
+ var request = new UpdatePostRequest { Content = content, Username = username };
+ var json = JsonSerializer.Serialize(request, JsonOptions);
+ var content_ = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await _httpClient.PatchAsync($"posts/{postId}", content_);
+ response.EnsureSuccessStatusCode();
+ var responseJson = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(responseJson, JsonOptions)!;
+ }
+
+ public async Task DeletePostAsync(string postId)
+ {
+ SetAuthHeaders();
+ var response = await _httpClient.DeleteAsync($"posts/{postId}");
+ response.EnsureSuccessStatusCode();
+ return;
+ }
+
+ public async Task LikePostAsync(string postId, string username)
+ {
+ SetAuthHeaders();
+ var request = new LikeRequest { Username = username };
+ var json = JsonSerializer.Serialize(request, JsonOptions);
+ var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await _httpClient.PostAsync($"posts/{postId}/likes", content);
+ response.EnsureSuccessStatusCode();
+ var responseJson = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(responseJson, JsonOptions)!;
+ }
+
+ public async Task UnlikePostAsync(string postId, string username)
+ {
+ SetAuthHeaders();
+ var response = await _httpClient.DeleteAsync($"posts/{postId}/likes?username={username}");
+ response.EnsureSuccessStatusCode();
+ return;
+ }
+
+ public async Task> SearchPostsAsync(string query)
+ {
+ SetAuthHeaders();
+ var encodedQuery = Uri.EscapeDataString(query);
+ var response = await _httpClient.GetAsync($"posts/search?q={encodedQuery}");
+ response.EnsureSuccessStatusCode();
+ var json = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize>(json, JsonOptions) ?? new List();
+ }
+
+ // Comment API methods
+ public async Task> GetCommentsAsync(string postId)
+ {
+ SetAuthHeaders();
+ var response = await _httpClient.GetAsync($"posts/{postId}/comments");
+ response.EnsureSuccessStatusCode();
+ var json = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize>(json, JsonOptions) ?? new List();
+ }
+
+ public async Task CreateCommentAsync(string postId, string content, string username)
+ {
+ SetAuthHeaders();
+ var request = new CreateCommentRequest { Content = content, Username = username };
+ var json = JsonSerializer.Serialize(request, JsonOptions);
+ var content_ = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await _httpClient.PostAsync($"posts/{postId}/comments", content_);
+ response.EnsureSuccessStatusCode();
+ var responseJson = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(responseJson, JsonOptions)!;
+ }
+
+ public async Task GetCommentAsync(string postId, string commentId)
+ {
+ SetAuthHeaders();
+ var response = await _httpClient.GetAsync($"posts/{postId}/comments/{commentId}");
+ response.EnsureSuccessStatusCode();
+ var json = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(json, JsonOptions)!;
+ }
+
+ public async Task UpdateCommentAsync(string postId, string commentId, string content, string username)
+ {
+ SetAuthHeaders();
+ var request = new UpdateCommentRequest { Content = content, Username = username };
+ var json = JsonSerializer.Serialize(request, JsonOptions);
+ var content_ = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await _httpClient.PatchAsync($"posts/{postId}/comments/{commentId}", content_);
+ response.EnsureSuccessStatusCode();
+ var responseJson = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(responseJson, JsonOptions)!;
+ }
+
+ public async Task DeleteCommentAsync(string postId, string commentId)
+ {
+ SetAuthHeaders();
+ var response = await _httpClient.DeleteAsync($"posts/{postId}/comments/{commentId}");
+ response.EnsureSuccessStatusCode();
+ }
+}
diff --git a/complete/dotnet/Contoso.BlazorApp/Services/AuthService.cs b/complete/dotnet/Contoso.BlazorApp/Services/AuthService.cs
new file mode 100644
index 0000000..5cc74dd
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/Services/AuthService.cs
@@ -0,0 +1,72 @@
+using System.Text.Json;
+using Contoso.BlazorApp.Models;
+using Microsoft.JSInterop;
+
+namespace Contoso.BlazorApp.Services;
+
+public class AuthService
+{
+ private readonly IJSRuntime _jsRuntime;
+ private AuthState _authState = new();
+
+ public event Action? OnAuthStateChanged;
+
+ public AuthService(IJSRuntime jsRuntime)
+ {
+ _jsRuntime = jsRuntime;
+ }
+
+ public AuthState AuthState => _authState;
+
+ public async Task InitializeAsync()
+ {
+ try
+ {
+ var userJson = await _jsRuntime.InvokeAsync("localStorage.getItem", "user");
+ if (!string.IsNullOrEmpty(userJson))
+ {
+ var user = JsonSerializer.Deserialize(userJson);
+ _authState.User = user;
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error initializing auth: {ex.Message}");
+ await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", "user");
+ }
+ finally
+ {
+ _authState.IsLoading = false;
+ OnAuthStateChanged?.Invoke();
+ }
+ }
+
+ public async Task LoginAsync(string username)
+ {
+ _authState.IsLoading = true;
+ OnAuthStateChanged?.Invoke();
+
+ try
+ {
+ var userData = new User { Username = username.Trim() };
+ _authState.User = userData;
+
+ var userJson = JsonSerializer.Serialize(userData);
+ await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "user", userJson);
+
+ return userData;
+ }
+ finally
+ {
+ _authState.IsLoading = false;
+ OnAuthStateChanged?.Invoke();
+ }
+ }
+
+ public async Task LogoutAsync()
+ {
+ _authState.User = null;
+ await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", "user");
+ OnAuthStateChanged?.Invoke();
+ }
+}
diff --git a/complete/dotnet/ContosoSnsWebApp/appsettings.json b/complete/dotnet/Contoso.BlazorApp/appsettings.Development.json
similarity index 64%
rename from complete/dotnet/ContosoSnsWebApp/appsettings.json
rename to complete/dotnet/Contoso.BlazorApp/appsettings.Development.json
index 10f68b8..81f2d61 100644
--- a/complete/dotnet/ContosoSnsWebApp/appsettings.json
+++ b/complete/dotnet/Contoso.BlazorApp/appsettings.Development.json
@@ -5,5 +5,7 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "ApiSettings": {
+ "BaseUrl": "http://localhost:8080/api/"
+ }
}
diff --git a/complete/dotnet/ContosoSnsWebApp/appsettings.Development.json b/complete/dotnet/Contoso.BlazorApp/appsettings.json
similarity index 56%
rename from complete/dotnet/ContosoSnsWebApp/appsettings.Development.json
rename to complete/dotnet/Contoso.BlazorApp/appsettings.json
index 0c208ae..862ae9f 100644
--- a/complete/dotnet/ContosoSnsWebApp/appsettings.Development.json
+++ b/complete/dotnet/Contoso.BlazorApp/appsettings.json
@@ -4,5 +4,9 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
+ },
+ "AllowedHosts": "*",
+ "ApiSettings": {
+ "BaseUrl": "http://localhost:8080/api/"
}
}
diff --git a/complete/dotnet/Contoso.BlazorApp/wwwroot/app.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/app.css
new file mode 100644
index 0000000..73a69d6
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/wwwroot/app.css
@@ -0,0 +1,60 @@
+html, body {
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+}
+
+a, .btn-link {
+ color: #006bb7;
+}
+
+.btn-primary {
+ color: #fff;
+ background-color: #1b6ec2;
+ border-color: #1861ac;
+}
+
+.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
+ box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
+}
+
+.content {
+ padding-top: 1.1rem;
+}
+
+h1:focus {
+ outline: none;
+}
+
+.valid.modified:not([type=checkbox]) {
+ outline: 1px solid #26b050;
+}
+
+.invalid {
+ outline: 1px solid #e50000;
+}
+
+.validation-message {
+ color: #e50000;
+}
+
+.blazor-error-boundary {
+ background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
+ padding: 1rem 1rem 1rem 3.7rem;
+ color: white;
+}
+
+ .blazor-error-boundary::after {
+ content: "An error has occurred."
+ }
+
+.darker-border-checkbox.form-check-input {
+ border-color: #929292;
+}
+
+.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
+ color: var(--bs-secondary-color);
+ text-align: end;
+}
+
+.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
+ text-align: start;
+}
\ No newline at end of file
diff --git a/complete/dotnet/Contoso.BlazorApp/wwwroot/css/app.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/css/app.css
new file mode 100644
index 0000000..176b454
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/wwwroot/css/app.css
@@ -0,0 +1,5 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Custom styles can be added below */
diff --git a/complete/dotnet/Contoso.BlazorApp/wwwroot/css/site.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/css/site.css
new file mode 100644
index 0000000..f566702
--- /dev/null
+++ b/complete/dotnet/Contoso.BlazorApp/wwwroot/css/site.css
@@ -0,0 +1,334 @@
+/* Tailwind CSS-like utilities for Blazor Social App */
+
+/* Reset and base styles */
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ background-color: #f9fafb;
+}
+
+/* Layout utilities */
+.flex { display: flex; }
+.flex-col { flex-direction: column; }
+.flex-1 { flex: 1 1 0%; }
+.items-center { align-items: center; }
+.justify-center { justify-content: center; }
+.justify-between { justify-content: space-between; }
+
+/* Gap utilities */
+.gap-1 { gap: 0.25rem; }
+.gap-2 { gap: 0.5rem; }
+.gap-4 { gap: 1rem; }
+.gap-6 { gap: 1.5rem; }
+.gap-8 { gap: 2rem; }
+
+/* Width utilities */
+.w-4 { width: 1rem; }
+.w-6 { width: 1.5rem; }
+.w-8 { width: 2rem; }
+.w-10 { width: 2.5rem; }
+.w-12 { width: 3rem; }
+.w-16 { width: 4rem; }
+.w-20 { width: 5rem; }
+.w-full { width: 100%; }
+.max-w-2xl { max-width: 42rem; }
+.min-h-screen { min-height: 100vh; }
+
+/* Height utilities */
+.h-4 { height: 1rem; }
+.h-6 { height: 1.5rem; }
+.h-8 { height: 2rem; }
+.h-10 { height: 2.5rem; }
+.h-12 { height: 3rem; }
+.h-16 { height: 4rem; }
+.h-screen { height: 100vh; }
+.min-h-150 { min-height: 150px; }
+
+/* Padding utilities */
+.p-2 { padding: 0.5rem; }
+.p-4 { padding: 1rem; }
+.px-4 { padding-left: 1rem; padding-right: 1rem; }
+.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
+.px-8 { padding-left: 2rem; padding-right: 2rem; }
+.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
+.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
+.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
+.py-10 { padding-top: 2.5rem; padding-bottom: 2.5rem; }
+
+/* Margin utilities */
+.m-2 { margin: 0.5rem; }
+.m-4 { margin: 1rem; }
+.mx-auto { margin-left: auto; margin-right: auto; }
+.mb-2 { margin-bottom: 0.5rem; }
+.mb-4 { margin-bottom: 1rem; }
+.mb-6 { margin-bottom: 1.5rem; }
+.mr-2 { margin-right: 0.5rem; }
+.mt-2 { margin-top: 0.5rem; }
+.ml-20 { margin-left: 5rem; }
+
+/* Background colors */
+.bg-white { background-color: #ffffff; }
+.bg-gray-50 { background-color: #f9fafb; }
+.bg-gray-100 { background-color: #f3f4f6; }
+.bg-gray-200 { background-color: #e5e7eb; }
+.bg-gray-700 { background-color: #374151; }
+.bg-gray-800 { background-color: #1f2937; }
+.bg-gray-900 { background-color: #111827; }
+.bg-blue-100 { background-color: #dbeafe; }
+.bg-blue-600 { background-color: #2563eb; }
+.bg-red-500 { background-color: #ef4444; }
+
+/* Text utilities */
+.text-xs { font-size: 0.75rem; line-height: 1rem; }
+.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
+.text-base { font-size: 1rem; line-height: 1.5rem; }
+.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
+.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
+.text-2xl { font-size: 1.5rem; line-height: 2rem; }
+
+.font-bold { font-weight: 700; }
+
+/* Text colors */
+.text-white { color: #ffffff; }
+.text-gray-400 { color: #9ca3af; }
+.text-gray-500 { color: #6b7280; }
+.text-gray-800 { color: #1f2937; }
+.text-gray-900 { color: #111827; }
+.text-blue-600 { color: #2563eb; }
+.text-red-500 { color: #ef4444; }
+
+/* Border utilities */
+.border { border-width: 1px; }
+.border-b { border-bottom-width: 1px; }
+.border-r { border-right-width: 1px; }
+.border-gray-200 { border-color: #e5e7eb; }
+.border-gray-700 { border-color: #374151; }
+
+/* Border radius */
+.rounded-md { border-radius: 0.375rem; }
+.rounded-full { border-radius: 9999px; }
+
+/* Position utilities */
+.fixed { position: fixed; }
+.relative { position: relative; }
+.absolute { position: absolute; }
+.top-0 { top: 0px; }
+.left-0 { left: 0px; }
+.right-0 { right: 0px; }
+.bottom-0 { bottom: 0px; }
+.z-40 { z-index: 40; }
+.z-50 { z-index: 50; }
+
+/* Cursor utilities */
+.cursor-pointer { cursor: pointer; }
+.cursor-not-allowed { cursor: not-allowed; }
+
+/* Transition utilities */
+.transition-colors {
+ transition-property: color, background-color, border-color;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+.transition-opacity {
+ transition-property: opacity;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+
+/* Hover states */
+.hover-bg-gray-100:hover { background-color: #f3f4f6; }
+.hover-bg-gray-800:hover { background-color: #1f2937; }
+.hover-text-red-500:hover { color: #ef4444; }
+
+/* Focus states */
+.focus-outline-none:focus { outline: 2px solid transparent; outline-offset: 2px; }
+.focus-ring-2:focus {
+ box-shadow: 0 0 0 2px #2563eb;
+}
+
+/* Disabled states */
+.disabled-opacity-70:disabled { opacity: 0.7; }
+.disabled-cursor-not-allowed:disabled { cursor: not-allowed; }
+
+/* Text utilities */
+.resize-vertical { resize: vertical; }
+.break-words { overflow-wrap: break-word; }
+.leading-relaxed { line-height: 1.625; }
+.text-center { text-align: center; }
+
+/* Component-specific styles */
+.floating-action-button {
+ position: fixed;
+ bottom: 2rem;
+ right: 2rem;
+ width: 3.5rem;
+ height: 3.5rem;
+ background-color: #2563eb;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-size: 1.5rem;
+ cursor: pointer;
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
+ transition: background-color 0.15s ease-in-out;
+ border: none;
+}
+
+.floating-action-button:hover {
+ background-color: #1d4ed8;
+}
+
+.modal-backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 50;
+}
+
+.modal-content {
+ background-color: white;
+ border-radius: 0.5rem;
+ padding: 2rem;
+ max-width: 32rem;
+ width: 90%;
+ margin: 1rem;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 50%;
+ transition: all 0.15s ease-in-out;
+ color: #6b7280;
+ text-decoration: none;
+}
+
+.nav-item:hover {
+ background-color: #f3f4f6;
+ color: #374151;
+}
+
+.nav-item.active {
+ background-color: #dbeafe;
+ color: #2563eb;
+}
+
+.post-card {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ max-width: 42rem;
+ background-color: white;
+ border-bottom: 1px solid #e5e7eb;
+ padding: 1rem;
+ cursor: pointer;
+}
+
+.post-card:hover {
+ background-color: #f9fafb;
+}
+
+.textarea-custom {
+ width: 100%;
+ min-height: 150px;
+ background-color: #f3f4f6;
+ border-radius: 0.375rem;
+ padding: 1rem;
+ font-size: 1rem;
+ color: #111827;
+ border: 1px solid #d1d5db;
+ resize: vertical;
+}
+
+.textarea-custom:focus {
+ outline: none;
+ border-color: #2563eb;
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
+}
+
+.textarea-custom::placeholder {
+ color: #9ca3af;
+}
+
+.btn {
+ border-radius: 0.375rem;
+ padding: 0.75rem 2rem;
+ font-size: 0.875rem;
+ transition: all 0.15s ease-in-out;
+ border: none;
+ cursor: pointer;
+}
+
+.btn-primary {
+ background-color: #2563eb;
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background-color: #1d4ed8;
+}
+
+.btn-secondary {
+ background-color: #e5e7eb;
+ color: #374151;
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background-color: #d1d5db;
+}
+
+.btn:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
+/* Responsive design */
+@media (max-width: 640px) {
+ .ml-20 { margin-left: 0; }
+ .w-20 { width: 100%; }
+ .nav-sidebar {
+ position: relative;
+ width: 100%;
+ height: auto;
+ flex-direction: row;
+ padding: 1rem;
+ }
+ .main-content {
+ margin-left: 0;
+ width: 100%;
+ }
+}
+
+/* Custom scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #f1f1f1;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #c1c1c1;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #a1a1a1;
+}
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/favicon.png b/complete/dotnet/Contoso.BlazorApp/wwwroot/favicon.png
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/favicon.png
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/favicon.png
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.js b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.js
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.js
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.js
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map b/complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map
similarity index 100%
rename from complete/dotnet/ContosoSnsWebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map
rename to complete/dotnet/Contoso.BlazorApp/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map
diff --git a/complete/dotnet/ContosoSnsWebApp.sln b/complete/dotnet/ContosoSnsWebApp.sln
deleted file mode 100644
index 1571bdf..0000000
--- a/complete/dotnet/ContosoSnsWebApp.sln
+++ /dev/null
@@ -1,34 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.0.31903.59
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContosoSnsWebApp", "ContosoSnsWebApp\ContosoSnsWebApp.csproj", "{D7288AEF-CF49-4790-9EE7-0E5587B7C06E}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Debug|x64 = Debug|x64
- Debug|x86 = Debug|x86
- Release|Any CPU = Release|Any CPU
- Release|x64 = Release|x64
- Release|x86 = Release|x86
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {D7288AEF-CF49-4790-9EE7-0E5587B7C06E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {D7288AEF-CF49-4790-9EE7-0E5587B7C06E}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {D7288AEF-CF49-4790-9EE7-0E5587B7C06E}.Debug|x64.ActiveCfg = Debug|Any CPU
- {D7288AEF-CF49-4790-9EE7-0E5587B7C06E}.Debug|x64.Build.0 = Debug|Any CPU
- {D7288AEF-CF49-4790-9EE7-0E5587B7C06E}.Debug|x86.ActiveCfg = Debug|Any CPU
- {D7288AEF-CF49-4790-9EE7-0E5587B7C06E}.Debug|x86.Build.0 = Debug|Any CPU
- {D7288AEF-CF49-4790-9EE7-0E5587B7C06E}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {D7288AEF-CF49-4790-9EE7-0E5587B7C06E}.Release|Any CPU.Build.0 = Release|Any CPU
- {D7288AEF-CF49-4790-9EE7-0E5587B7C06E}.Release|x64.ActiveCfg = Release|Any CPU
- {D7288AEF-CF49-4790-9EE7-0E5587B7C06E}.Release|x64.Build.0 = Release|Any CPU
- {D7288AEF-CF49-4790-9EE7-0E5587B7C06E}.Release|x86.ActiveCfg = Release|Any CPU
- {D7288AEF-CF49-4790-9EE7-0E5587B7C06E}.Release|x86.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
-EndGlobal
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/Button.razor b/complete/dotnet/ContosoSnsWebApp/Components/Button.razor
deleted file mode 100644
index a039756..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Components/Button.razor
+++ /dev/null
@@ -1,30 +0,0 @@
-
-@namespace ContosoSnsWebApp.Components
-
-
-
-@code {
- [Parameter] public RenderFragment? ChildContent { get; set; }
- [Parameter] public EventCallback OnClick { get; set; }
- [Parameter] public string Variant { get; set; } = "primary"; // primary, secondary, outline, danger, etc.
- [Parameter] public bool Small { get; set; }
- [Parameter] public bool Disabled { get; set; }
- [Parameter] public string Type { get; set; } = "button"; // button, submit, reset
- [Parameter] public string? CssClass { get; set; } // Allow additional custom classes
-
- private string ButtonClass => Variant switch
- {
- "primary" => "btn-primary",
- "secondary" => "btn-secondary",
- "outline" => "btn-outline-primary", // Example mapping for outline
- "danger" => "btn-danger",
- _ => "btn-primary" // Default
- };
-
- private string SizeClass => Small ? "btn-sm" : "";
-}
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/CommentSection.razor b/complete/dotnet/ContosoSnsWebApp/Components/CommentSection.razor
deleted file mode 100644
index 0e0e45a..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Components/CommentSection.razor
+++ /dev/null
@@ -1,160 +0,0 @@
-
-@using ContosoSnsWebApp.Models
-@using ContosoSnsWebApp.Services
-@inject ApiService ApiService
-
-
-
댓글 (@(comments?.Count ?? 0))
-
- @if (isLoading)
- {
-
댓글 로딩 중...
- }
- else if (!string.IsNullOrEmpty(loadError))
- {
-
@loadError
- }
- else if (comments == null || comments.Count == 0)
- {
-
아직 댓글이 없습니다.
- }
- else
- {
-
- @foreach (var comment in comments.OrderByDescending(c => c.CreatedAt))
- {
-
-
-
@comment.UserName
- @FormatDate(comment.CreatedAt)
-
-
@comment.Content
-
- }
-
- }
-
-
-
-
-
-
-
- @if (!string.IsNullOrEmpty(submitError))
- {
- @submitError
- }
-
-
-
-
-@code {
- [Parameter, EditorRequired] public int PostId { get; set; }
- [Parameter] public string? UserName { get; set; } // Current user's name
-
- private List? comments;
- private NewCommentRequest newCommentRequest = new("", "");
- private bool isLoading = true;
- private bool isSubmitting = false;
- private string? loadError = null;
- private string? submitError = null;
-
- protected override async Task OnInitializedAsync()
- {
- await LoadCommentsAsync();
- if (!string.IsNullOrEmpty(UserName))
- {
- // newCommentRequest = newCommentRequest with { UserName = UserName };
- newCommentRequest.UserName = UserName; // Ensure username is set
- }
- }
-
- protected override async Task OnParametersSetAsync()
- {
- // Reload comments if PostId changes
- // This might happen if the modal is reused without full disposal
- if (PostId > 0 && (comments == null || comments.All(c => c.PostId != PostId)))
- {
- await LoadCommentsAsync();
- }
-
- // Update username for new comment if it changes
- if (!string.IsNullOrEmpty(UserName) && newCommentRequest.UserName != UserName)
- {
- // newCommentRequest = newCommentRequest with { UserName = UserName };
- newCommentRequest.UserName = UserName; // Ensure username is set
- }
- }
-
- private async Task LoadCommentsAsync()
- {
- if (PostId <= 0) return;
-
- isLoading = true;
- loadError = null;
- StateHasChanged();
- try
- {
- comments = await ApiService.GetCommentsAsync(PostId);
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Error loading comments: {ex.Message}");
- loadError = "댓글을 불러오는 데 실패했습니다.";
- }
- finally
- {
- isLoading = false;
- StateHasChanged();
- }
- }
-
- private async Task HandleCommentSubmitAsync()
- {
- if (isSubmitting || PostId <= 0 || string.IsNullOrWhiteSpace(UserName))
- {
- submitError = "사용자 이름이 필요합니다."; // Should ideally not happen if UserName is passed correctly
- return;
- }
-
- isSubmitting = true;
- submitError = null;
- StateHasChanged();
-
- // newCommentRequest = newCommentRequest with { UserName = UserName }; // Ensure username is set
- newCommentRequest.UserName = UserName; // Ensure username is set
-
- try
- {
- var createdComment = await ApiService.CreateCommentAsync(PostId, newCommentRequest);
- if (createdComment != null)
- {
- comments ??= [];
- comments.Add(createdComment);
- newCommentRequest = new(UserName, ""); // Reset content, keep username
- }
- else
- {
- submitError = "댓글 등록에 실패했습니다.";
- }
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Error submitting comment: {ex.Message}");
- submitError = "댓글 등록 중 오류가 발생했습니다.";
- }
- finally
- {
- isSubmitting = false;
- StateHasChanged();
- }
- }
-
- private string FormatDate(DateTime? date)
- {
- // More detailed format for comments might be nice
- return date?.ToString("yyyy-MM-dd HH:mm") ?? string.Empty;
- }
-}
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/Layout/MainLayout.razor b/complete/dotnet/ContosoSnsWebApp/Components/Layout/MainLayout.razor
deleted file mode 100644
index 71397e3..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Components/Layout/MainLayout.razor
+++ /dev/null
@@ -1,36 +0,0 @@
-@inherits LayoutComponentBase
-
-
-
-
-
-
아웃도어 컴퍼니를 위한 소셜 미디어 플랫폼
-
-
-
-
- @Body
-
-
-
-
-
-
- An unhandled error has occurred.
-
Reload
-
🗙
-
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/Layout/NavMenu.razor b/complete/dotnet/ContosoSnsWebApp/Components/Layout/NavMenu.razor
deleted file mode 100644
index e8fed48..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Components/Layout/NavMenu.razor
+++ /dev/null
@@ -1,2 +0,0 @@
-@* Remove default NavMenu content as it's not used in this layout *@
-
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/NewPostForm.razor b/complete/dotnet/ContosoSnsWebApp/Components/NewPostForm.razor
deleted file mode 100644
index c70b26c..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Components/NewPostForm.razor
+++ /dev/null
@@ -1,133 +0,0 @@
-@using ContosoSnsWebApp.Models
-@using ContosoSnsWebApp.Services
-@inject ApiService ApiService
-@inject IJSRuntime JSRuntime
-
-
-
-
새 포스트 작성
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- @if (!string.IsNullOrEmpty(errorMessage))
- {
-
- @errorMessage
-
- }
-
-
-
-
-@code {
- [Parameter] public EventCallback OnPostCreated { get; set; }
- [Parameter] public string? InitialUserName { get; set; } // Passed from parent if needed
-
- private NewPostRequest newPostRequest = new(); // Initialize with parameterless constructor
- private bool isSubmitting = false;
- private string? errorMessage = null;
-
- protected override async Task OnInitializedAsync()
- {
- // Load username and set it in the request object
- var loadedUserName = await LoadUserNameFromLocalStorage() ?? InitialUserName ?? "";
- newPostRequest = new NewPostRequest { UserName = loadedUserName }; // Use object initializer
- }
-
- private async Task HandleSubmitAsync()
- {
- // Use newPostRequest.UserName directly
- if (isSubmitting || string.IsNullOrWhiteSpace(newPostRequest.UserName)) return;
-
- isSubmitting = true;
- errorMessage = null;
- StateHasChanged();
-
- // UserName is already part of newPostRequest
-
- try
- {
- var createdPost = await ApiService.CreatePostAsync(newPostRequest);
- if (createdPost != null)
- {
- // Reset form, keep username
- var currentUserName = newPostRequest.UserName; // Store username before reset
- newPostRequest = new() { UserName = currentUserName };
- await OnPostCreated.InvokeAsync();
- }
- else
- {
- errorMessage = "포스트 등록에 실패했습니다.";
- }
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Error creating post: {ex.Message}");
- errorMessage = "포스트 등록 중 오류가 발생했습니다.";
- }
- finally
- {
- isSubmitting = false;
- StateHasChanged();
- }
- }
-
- // Add this method to handle username input changes and save to local storage
- private async Task UserNameChanged(ChangeEventArgs e)
- {
- var newUserName = e.Value?.ToString();
- if (!string.IsNullOrEmpty(newUserName))
- {
- newPostRequest.UserName = newUserName;
- await SaveUserNameToLocalStorage(newUserName);
- }
- }
-
- // Local Storage Interaction
- private async Task LoadUserNameFromLocalStorage()
- {
- try
- {
- return await JSRuntime.InvokeAsync("localStorage.getItem", "userName");
- }
- catch (Exception ex) // Catch JSDisconnectedException or other errors
- {
- Console.Error.WriteLine($"Error loading username from local storage: {ex.Message}");
- return null;
- }
- }
-
- private async Task SaveUserNameToLocalStorage(string userName)
- {
- try
- {
- await JSRuntime.InvokeVoidAsync("localStorage.setItem", "userName", userName);
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Error saving username to local storage: {ex.Message}");
- }
- }
-}
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/Pages/Home.razor b/complete/dotnet/ContosoSnsWebApp/Components/Pages/Home.razor
deleted file mode 100644
index 7d732e9..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Components/Pages/Home.razor
+++ /dev/null
@@ -1,239 +0,0 @@
-@page "/"
-@using ContosoSnsWebApp.Models
-@using ContosoSnsWebApp.Services
-@using ContosoSnsWebApp.Components
-@inject ApiService ApiService
-@inject IJSRuntime JSRuntime
-@inject NavigationManager NavigationManager
-@rendermode InteractiveServer
-
-Contoso 아웃도어 소셜
-
-@* Header is now part of MainLayout *@
-
-
-
- @* Left Sidebar - Desktop Only *@
-
-
-
-
Contoso 아웃도어
-
- 최고 품질의 아웃도어 장비와 액세서리를 제공합니다.
- 자연을 탐험하고 모험을 즐기세요!
-
-
-
-
인기 태그
-
- #등산
- #캠핑
- #트레킹
- #아웃도어
- #백패킹
-
-
-
-
-
-
- @* Main Content Area *@
-
- @* New Post Form *@
-
-
- @* Post List *@
-
-
최근 포스트
-
- @if (!string.IsNullOrEmpty(errorMessage))
- {
-
@errorMessage
- }
-
- @if (isLoading)
- {
-
-
- Loading...
-
-
포스트를 불러오는 중...
-
- }
- else if (posts == null || posts.Count == 0)
- {
-
-
-
아직 포스트가 없습니다.
-
첫 번째 포스트를 작성해 보세요!
-
-
- }
- else
- {
-
- @foreach (var post in posts.OrderByDescending(p => p.CreatedAt))
- {
-
- }
-
- }
-
-
-
- @* Right Sidebar - Desktop Only *@
-
-
-
-
신제품 소식
-
- -
-
새로운 경량 텐트 출시
- 초경량 2인용 텐트를 지금 만나보세요!
-
- -
-
여름 시즌 하이킹 부츠
- 더운 여름을 위한 특별 설계 부츠
-
- -
-
방수 재킷 할인 이벤트
- 이번 주까지 전 제품 20% 할인
-
-
-
-
-
-
-
사용 안내
-
- Contoso 아웃도어 제품과 관련된 경험을 공유하고 다른 사용자들과 소통하세요.
-
-
- - 제품 사진과 함께 리뷰 남기기
- - 다른 사용자 포스트에 댓글 달기
- - 유용한 팁과 노하우 공유하기
-
-
-
-
-
-
-
-@* Footer is now part of MainLayout *@
-
-@* Post Detail Modal *@
-@if (selectedPostId.HasValue)
-{
-
-}
-
-
-@code {
- private List? posts;
- private bool isLoading = true;
- private string? errorMessage;
- private string? userName; // Store username locally
- private int? selectedPostId;
-
- protected override async Task OnInitializedAsync()
- {
- userName = await LoadUserNameFromLocalStorage();
- await LoadPostsAsync();
- }
-
- private async Task LoadPostsAsync()
- {
- isLoading = true;
- errorMessage = null;
- StateHasChanged(); // Update UI to show loading state
-
- try
- {
- posts = await ApiService.GetPostsAsync();
- if (posts == null)
- {
- // ApiService already logs the error, set user-facing message
- errorMessage = "포스트를 불러오는데 실패했습니다. API 서버가 실행 중인지 확인하세요.";
- }
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Error loading posts: {ex.Message}");
- errorMessage = "포스트를 불러오는 중 오류가 발생했습니다.";
- posts = null; // Ensure posts is null on error
- }
- finally
- {
- isLoading = false;
- StateHasChanged(); // Update UI with data or error
- }
- }
-
- private async Task HandleDeletePostAsync(int postId)
- {
- var confirmed = await JSRuntime.InvokeAsync("confirm", "정말로 이 포스트를 삭제하시겠습니까?");
- if (!confirmed) return;
-
- try
- {
- var success = await ApiService.DeletePostAsync(postId);
- if (success)
- {
- posts?.RemoveAll(p => p.Id == postId);
- StateHasChanged(); // Update UI
- }
- else
- {
- await JSRuntime.InvokeVoidAsync("alert", "포스트 삭제에 실패했습니다.");
- }
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Error deleting post: {ex.Message}");
- await JSRuntime.InvokeVoidAsync("alert", "포스트 삭제 중 오류가 발생했습니다.");
- }
- }
-
- private void HandlePostClick(int postId)
- {
- selectedPostId = postId;
- StateHasChanged();
- }
-
- private void ClosePostDetail()
- {
- selectedPostId = null;
- StateHasChanged();
- }
-
- private async Task HandlePostDeletedFromDetail()
- {
- // Called when delete is successful from the detail modal
- selectedPostId = null; // Close modal
- await LoadPostsAsync(); // Refresh the list
- }
-
-
- // Local Storage Interaction (Could be moved to a service)
- private async Task LoadUserNameFromLocalStorage()
- {
- try
- {
- return await JSRuntime.InvokeAsync("localStorage.getItem", "userName");
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Error loading username: {ex.Message}");
- return null;
- }
- }
-
- // UserName is set and saved by NewPostForm component now
-}
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/PostCard.razor b/complete/dotnet/ContosoSnsWebApp/Components/PostCard.razor
deleted file mode 100644
index 0b37f59..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Components/PostCard.razor
+++ /dev/null
@@ -1,141 +0,0 @@
-
-@using ContosoSnsWebApp.Models
-@using ContosoSnsWebApp.Services
-@inject ApiService ApiService
-@inject IJSRuntime JSRuntime
-
-
- @if (!string.IsNullOrEmpty(Post?.ImageUrl))
- {
-
-

-
- }
-
-
- @Post?.UserName
- @FormatDate(Post?.CreatedAt)
-
-
@Post?.Content
-
-
-
-
-
- @if (Post?.UserName == UserName)
- {
-
- }
-
-
-
-
-@code {
- [Parameter, EditorRequired] public Post? Post { get; set; }
- [Parameter] public EventCallback OnDeleteClick { get; set; }
- [Parameter] public EventCallback OnCardClick { get; set; }
- [Parameter] public EventCallback RefreshPosts { get; set; } // Optional: To refresh list after like/unlike
- [Parameter] public string? UserName { get; set; }
-
- private bool isLiked = false; // In a real app, this should come from user-specific data
- private int likeCount = 0;
- private bool isLikeLoading = false;
-
- protected override void OnParametersSet()
- {
- if (Post != null)
- {
- likeCount = Post.LikeCount;
- // TODO: Determine initial 'isLiked' state based on current user
- }
- }
-
- private async Task HandleLikeToggle(MouseEventArgs e)
- {
- // e.StopPropagation(); // Prevent card click event
- if (isLikeLoading || Post == null || string.IsNullOrEmpty(UserName)) return;
-
- isLikeLoading = true;
- bool success = false;
- var likeRequest = new LikeRequest(UserName);
-
- try
- {
- if (isLiked)
- {
- success = await ApiService.UnlikePostAsync(Post.Id, likeRequest);
- if (success)
- {
- likeCount = Math.Max(0, likeCount - 1);
- isLiked = false;
- }
- }
- else
- {
- success = await ApiService.LikePostAsync(Post.Id, likeRequest);
- if (success)
- {
- likeCount++;
- isLiked = true;
- }
- }
- // Optionally call RefreshPosts if counts need global update
- // await RefreshPosts.InvokeAsync();
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Error toggling like: {ex.Message}");
- // Show error to user?
- }
- finally
- {
- isLikeLoading = false;
- StateHasChanged(); // Update UI
- }
- }
-
- private async Task HandleDeleteClick(MouseEventArgs e)
- {
- // e.StopPropagation(); // Prevent card click event
- if (Post != null)
- {
- await OnDeleteClick.InvokeAsync(Post.Id);
- }
- }
-
- private async Task HandleCardClick()
- {
- if (Post != null)
- {
- await OnCardClick.InvokeAsync(Post.Id);
- }
- }
-
- private string FormatDate(DateTime? date)
- {
- return date?.ToString("yyyy년 M월 d일") ?? string.Empty;
- }
-}
-
-
diff --git a/complete/dotnet/ContosoSnsWebApp/Components/PostDetail.razor b/complete/dotnet/ContosoSnsWebApp/Components/PostDetail.razor
deleted file mode 100644
index 3e0ddf4..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Components/PostDetail.razor
+++ /dev/null
@@ -1,145 +0,0 @@
-
-@using ContosoSnsWebApp.Models
-@using ContosoSnsWebApp.Services
-@inject ApiService ApiService
-@inject IJSRuntime JSRuntime
-
-
-
-@code {
- [Parameter, EditorRequired] public int PostId { get; set; }
- [Parameter] public EventCallback OnClose { get; set; }
- [Parameter] public EventCallback OnDeleteSuccess { get; set; } // Notify parent on successful delete
- [Parameter] public string? UserName { get; set; } // Current user's name
-
- private Post? post;
- private bool isLoading = true;
- private bool isDeleting = false;
-
- protected override async Task OnParametersSetAsync()
- {
- // Load post details when PostId is set or changes
- if (PostId > 0 && (post == null || post.Id != PostId))
- {
- await LoadPostDetailsAsync();
- }
- }
-
- private async Task LoadPostDetailsAsync()
- {
- isLoading = true;
- StateHasChanged();
- try
- {
- post = await ApiService.GetPostAsync(PostId);
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Error loading post details: {ex.Message}");
- post = null; // Ensure post is null on error
- }
- finally
- {
- isLoading = false;
- StateHasChanged();
- }
- }
-
- private async Task HandleDeleteAsync()
- {
- if (post == null || isDeleting) return;
-
- var confirmed = await JSRuntime.InvokeAsync("confirm", "정말로 이 포스트를 삭제하시겠습니까?");
- if (!confirmed) return;
-
- isDeleting = true;
- StateHasChanged();
-
- try
- {
- var success = await ApiService.DeletePostAsync(post.Id);
- if (success)
- {
- await OnDeleteSuccess.InvokeAsync(); // Notify parent to refresh list
- await CloseModal(); // Close modal after successful deletion
- }
- else
- {
- await JSRuntime.InvokeVoidAsync("alert", "포스트 삭제에 실패했습니다.");
- }
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Error deleting post: {ex.Message}");
- await JSRuntime.InvokeVoidAsync("alert", "포스트 삭제 중 오류가 발생했습니다.");
- }
- finally
- {
- isDeleting = false;
- // Avoid StateHasChanged if modal is closing anyway
- if (post != null) // Check if deletion failed
- {
- StateHasChanged();
- }
- }
- }
-
-
- private async Task CloseModal()
- {
- post = null; // Clear post data when closing
- await OnClose.InvokeAsync();
- }
-
- private string FormatDate(DateTime? date)
- {
- return date?.ToString("yyyy년 M월 d일 HH:mm") ?? string.Empty;
- }
-}
diff --git a/complete/dotnet/ContosoSnsWebApp/Models/Comment.cs b/complete/dotnet/ContosoSnsWebApp/Models/Comment.cs
deleted file mode 100644
index 4d649b8..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Models/Comment.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-
-using System.Text.Json.Serialization;
-
-namespace ContosoSnsWebApp.Models;
-
-public sealed record Comment(
- [property: JsonPropertyName("id")] int Id,
- [property: JsonPropertyName("postId")] int PostId,
- [property: JsonPropertyName("userName")] string UserName,
- [property: JsonPropertyName("content")] string Content,
- [property: JsonPropertyName("createdAt")] DateTime CreatedAt
-);
diff --git a/complete/dotnet/ContosoSnsWebApp/Models/LikeRequest.cs b/complete/dotnet/ContosoSnsWebApp/Models/LikeRequest.cs
deleted file mode 100644
index fd5e5c8..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Models/LikeRequest.cs
+++ /dev/null
@@ -1,4 +0,0 @@
-
-namespace ContosoSnsWebApp.Models;
-
-public sealed record LikeRequest(string UserName);
diff --git a/complete/dotnet/ContosoSnsWebApp/Models/NewCommentRequest.cs b/complete/dotnet/ContosoSnsWebApp/Models/NewCommentRequest.cs
deleted file mode 100644
index 9e031a5..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Models/NewCommentRequest.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace ContosoSnsWebApp.Models;
-
-// Changed from record to class with mutable properties for Blazor binding
-public sealed class NewCommentRequest
-{
- public string UserName { get; set; } = "";
- public string Content { get; set; } = "";
-
- // Add a constructor for easier initialization if needed
- public NewCommentRequest(string userName, string content)
- {
- UserName = userName;
- Content = content;
- }
- // Add a parameterless constructor required by some frameworks/serializers
- public NewCommentRequest() { }
-}
diff --git a/complete/dotnet/ContosoSnsWebApp/Models/NewPostRequest.cs b/complete/dotnet/ContosoSnsWebApp/Models/NewPostRequest.cs
deleted file mode 100644
index 0976168..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Models/NewPostRequest.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace ContosoSnsWebApp.Models;
-
-// Changed from record to class with mutable properties for Blazor binding
-public sealed class NewPostRequest
-{
- public string UserName { get; set; } = "";
- public string Content { get; set; } = "";
- public string? ImageUrl { get; set; }
-
- // Add a constructor for easier initialization if needed
- public NewPostRequest(string userName, string content, string? imageUrl = null)
- {
- UserName = userName;
- Content = content;
- ImageUrl = imageUrl;
- }
-
- // Add a parameterless constructor required by some frameworks/serializers
- public NewPostRequest() { }
-}
diff --git a/complete/dotnet/ContosoSnsWebApp/Models/Post.cs b/complete/dotnet/ContosoSnsWebApp/Models/Post.cs
deleted file mode 100644
index 55d7ad0..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Models/Post.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-
-using System.Text.Json.Serialization;
-
-namespace ContosoSnsWebApp.Models;
-
-public sealed record Post(
- [property: JsonPropertyName("id")] int Id,
- [property: JsonPropertyName("userName")] string UserName,
- [property: JsonPropertyName("content")] string Content,
- [property: JsonPropertyName("imageUrl")] string? ImageUrl,
- [property: JsonPropertyName("createdAt")] DateTime CreatedAt,
- [property: JsonPropertyName("likeCount")] int LikeCount,
- [property: JsonPropertyName("commentCount")] int CommentCount
-);
diff --git a/complete/dotnet/ContosoSnsWebApp/Services/ApiService.cs b/complete/dotnet/ContosoSnsWebApp/Services/ApiService.cs
deleted file mode 100644
index be905db..0000000
--- a/complete/dotnet/ContosoSnsWebApp/Services/ApiService.cs
+++ /dev/null
@@ -1,139 +0,0 @@
-using System.Net.Http.Json;
-using ContosoSnsWebApp.Models;
-using Microsoft.Extensions.Configuration;
-
-namespace ContosoSnsWebApp.Services;
-
-public sealed class ApiService
-{
- private readonly HttpClient _httpClient;
- private readonly string _apiBaseUrl;
-
- public ApiService(HttpClient httpClient, IConfiguration configuration)
- {
- ArgumentNullException.ThrowIfNull(httpClient);
- ArgumentNullException.ThrowIfNull(configuration);
-
- _httpClient = httpClient;
- // Use the environment variable with fallback to a local default
- _apiBaseUrl = $"{(configuration["ApiBaseUrl"] ?? "http://localhost:8080").TrimEnd('/')}/api";
- }
-
- public async Task?> GetPostsAsync(CancellationToken cancellationToken = default)
- {
- try
- {
- return await _httpClient.GetFromJsonAsync>($"{_apiBaseUrl}/posts", cancellationToken);
- }
- catch (HttpRequestException ex)
- {
- Console.Error.WriteLine($"Error fetching posts: {ex.Message}");
- // In a real app, use a proper logging framework
- return null;
- }
- }
-
- public async Task GetPostAsync(int postId, CancellationToken cancellationToken = default)
- {
- try
- {
- return await _httpClient.GetFromJsonAsync($"{_apiBaseUrl}/posts/{postId}", cancellationToken);
- }
- catch (HttpRequestException ex)
- {
- Console.Error.WriteLine($"Error fetching post {postId}: {ex.Message}");
- return null;
- }
- }
-
- public async Task CreatePostAsync(NewPostRequest postData, CancellationToken cancellationToken = default)
- {
- try
- {
- var response = await _httpClient.PostAsJsonAsync($"{_apiBaseUrl}/posts", postData, cancellationToken);
- response.EnsureSuccessStatusCode();
- return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
- }
- catch (HttpRequestException ex)
- {
- Console.Error.WriteLine($"Error creating post: {ex.Message}");
- return null;
- }
- }
-
- public async Task DeletePostAsync(int postId, CancellationToken cancellationToken = default)
- {
- try
- {
- var response = await _httpClient.DeleteAsync($"{_apiBaseUrl}/posts/{postId}", cancellationToken);
- return response.IsSuccessStatusCode;
- }
- catch (HttpRequestException ex)
- {
- Console.Error.WriteLine($"Error deleting post {postId}: {ex.Message}");
- return false;
- }
- }
-
- public async Task?> GetCommentsAsync(int postId, CancellationToken cancellationToken = default)
- {
- try
- {
- return await _httpClient.GetFromJsonAsync>($"{_apiBaseUrl}/posts/{postId}/comments", cancellationToken);
- }
- catch (HttpRequestException ex)
- {
- Console.Error.WriteLine($"Error fetching comments for post {postId}: {ex.Message}");
- return null;
- }
- }
-
- public async Task CreateCommentAsync(int postId, NewCommentRequest commentData, CancellationToken cancellationToken = default)
- {
- try
- {
- var response = await _httpClient.PostAsJsonAsync($"{_apiBaseUrl}/posts/{postId}/comments", commentData, cancellationToken);
- response.EnsureSuccessStatusCode();
- return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken);
- }
- catch (HttpRequestException ex)
- {
- Console.Error.WriteLine($"Error creating comment for post {postId}: {ex.Message}");
- return null;
- }
- }
-
- public async Task LikePostAsync(int postId, LikeRequest likeData, CancellationToken cancellationToken = default)
- {
- try
- {
- var response = await _httpClient.PostAsJsonAsync($"{_apiBaseUrl}/posts/{postId}/likes", likeData, cancellationToken);
- return response.IsSuccessStatusCode;
- }
- catch (HttpRequestException ex)
- {
- Console.Error.WriteLine($"Error liking post {postId}: {ex.Message}");
- return false;
- }
- }
-
- public async Task UnlikePostAsync(int postId, LikeRequest unlikeData, CancellationToken cancellationToken = default)
- {
- try
- {
- // The React code uses DELETE, but the Java backend might expect POST with specific data or a different endpoint.
- // Assuming DELETE for now based on React code. Adjust if backend differs.
- var request = new HttpRequestMessage(HttpMethod.Delete, $"{_apiBaseUrl}/posts/{postId}/likes")
- {
- Content = JsonContent.Create(unlikeData)
- };
- var response = await _httpClient.SendAsync(request, cancellationToken);
- return response.IsSuccessStatusCode;
- }
- catch (HttpRequestException ex)
- {
- Console.Error.WriteLine($"Error unliking post {postId}: {ex.Message}");
- return false;
- }
- }
-}
diff --git a/complete/dotnet/ContosoSnsWebApp/wwwroot/app.css b/complete/dotnet/ContosoSnsWebApp/wwwroot/app.css
deleted file mode 100644
index c2e63a4..0000000
--- a/complete/dotnet/ContosoSnsWebApp/wwwroot/app.css
+++ /dev/null
@@ -1,42 +0,0 @@
-/* Replace existing content with Tailwind directives */
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-/* Custom styles from React app (if any) could be added below */
-
-/* Add styles for Blazor-specific elements if needed */
-.validation-message {
- color: red;
- font-size: 0.875em;
-}
-
-/* Style for modal backdrop */
-.modal-backdrop {
- position: fixed;
- top: 0;
- left: 0;
- z-index: 1040;
- width: 100vw;
- height: 100vh;
- background-color: #000;
- opacity: 0.5;
-}
-
-/* Basic styling for Blazor modal */
-.modal.fade.show {
- display: block;
-}
-
-/* Ensure Bootstrap icons are available if used (e.g., in Home.razor sidebar) */
-@import url("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css");
-
-/* Add hover effect similar to React PostCard */
-.hover-shadow-lg:hover {
- box-shadow: 0 1rem 3rem rgba(0,0,0,.175) !important;
- transition: box-shadow 0.3s ease-in-out;
-}
-
-.card {
- cursor: pointer;
-}
\ No newline at end of file
diff --git a/complete/dotnet/ContosoWebApp.sln b/complete/dotnet/ContosoWebApp.sln
new file mode 100644
index 0000000..6d1c393
--- /dev/null
+++ b/complete/dotnet/ContosoWebApp.sln
@@ -0,0 +1,34 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.BlazorApp", "Contoso.BlazorApp\Contoso.BlazorApp.csproj", "{9D4A4814-CFFC-4057-B656-79EE4C6D83CB}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {9D4A4814-CFFC-4057-B656-79EE4C6D83CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9D4A4814-CFFC-4057-B656-79EE4C6D83CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9D4A4814-CFFC-4057-B656-79EE4C6D83CB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9D4A4814-CFFC-4057-B656-79EE4C6D83CB}.Debug|x64.Build.0 = Debug|Any CPU
+ {9D4A4814-CFFC-4057-B656-79EE4C6D83CB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9D4A4814-CFFC-4057-B656-79EE4C6D83CB}.Debug|x86.Build.0 = Debug|Any CPU
+ {9D4A4814-CFFC-4057-B656-79EE4C6D83CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9D4A4814-CFFC-4057-B656-79EE4C6D83CB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9D4A4814-CFFC-4057-B656-79EE4C6D83CB}.Release|x64.ActiveCfg = Release|Any CPU
+ {9D4A4814-CFFC-4057-B656-79EE4C6D83CB}.Release|x64.Build.0 = Release|Any CPU
+ {9D4A4814-CFFC-4057-B656-79EE4C6D83CB}.Release|x86.ActiveCfg = Release|Any CPU
+ {9D4A4814-CFFC-4057-B656-79EE4C6D83CB}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/complete/dotnet/README.md b/complete/dotnet/README.md
index e3c00bf..b7578c0 100644
--- a/complete/dotnet/README.md
+++ b/complete/dotnet/README.md
@@ -8,7 +8,9 @@ Refer to the [README](../../README.md) doc for preparation.
### Run Spring Boot Backend
-Use [Java App Sample](../complete/java/).
+Use [Java App Sample](../java/).
+
+> **NOTE**: If you use GitHub Codespaces, make sure that the Java app port, `8080`, is set to **public**.
### Run Blazor Frontend
@@ -27,7 +29,7 @@ Use [Java App Sample](../complete/java/).
1. Run the app.
```bash
- dotnet watch run --project $REPOSITORY_ROOT/complete/dotnet/Contoso.BlazorApp/Contoso.BlazorApp.csproj
+ dotnet watch run --project $REPOSITORY_ROOT/complete/dotnet/Contoso.BlazorApp
```
1. Verify if the web application is running properly.
diff --git a/complete/java/README.md b/complete/java/README.md
index cda1855..492076d 100644
--- a/complete/java/README.md
+++ b/complete/java/README.md
@@ -1,9 +1,367 @@
# Java App Sample
-## Prerequisites
+A comprehensive Spring Boot REST API application for a social media platform with full CRUD operations for posts, comments, and likes.
+
+## Project Overview
+
+This is a production-ready Spring Boot application built with the following specifications:
+
+- **Package Name**: `com.contoso.socialapp`
+- **Artifact ID**: `socialapp`
+- **Group ID**: `com.contoso`
+- **Package Type**: `jar`
+- **Java Version**: OpenJDK 21
+- **Build Tool**: Gradle
+- **Database**: SQLite (embedded)
+- **Port**: 8080
+
+### Project Dependencies
+
+- **Spring Boot 3.2.5**: Core framework
+- **Spring Web**: RESTful API endpoints
+- **Spring Data JPA**: Database operations
+- **Spring Boot Actuator**: Application monitoring
+- **Spring Boot Validation**: Input validation
+- **SQLite**: Embedded database
+- **Hibernate Community Dialects**: SQLite support
+- **Springdoc OpenAPI**: API documentation (Swagger UI)
+- **Lombok**: Boilerplate code reduction
+
+### Project Structure
+
+```text
+src/
+├── main/
+│ ├── java/
+│ │ └── com/
+│ │ └── contoso/
+│ │ └── socialapp/
+│ │ ├── SocialAppApplication.java # Main application class
+│ │ ├── config/
+│ │ │ ├── WebConfig.java # CORS configuration
+│ │ │ └── OpenApiConfig.java # Swagger/OpenAPI config
+│ │ ├── controller/
+│ │ │ ├── HealthController.java # Health endpoints
+│ │ │ ├── PostController.java # Post management
+│ │ │ └── CommentController.java # Comment & like management
+│ │ ├── model/
+│ │ │ ├── Post.java # Post entity
+│ │ │ ├── Comment.java # Comment entity
+│ │ │ ├── Like.java # Like entity
+│ │ │ └── dto/ # Data Transfer Objects
+│ │ ├── repository/
+│ │ │ ├── PostRepository.java # Post data access
+│ │ │ ├── CommentRepository.java # Comment data access
+│ │ │ └── LikeRepository.java # Like data access
+│ │ └── service/
+│ │ ├── PostService.java # Post business logic
+│ │ └── CommentService.java # Comment business logic
+│ └── resources/
+│ ├── application.properties # Application configuration
+│ └── data.sql # Sample data (optional)
+└── test/
+ └── java/
+ └── com/
+ └── contoso/
+ └── socialapp/
+ └── SocialAppApplicationTests.java # Integration tests
+```
+
+## Features
+
+- ✅ Complete RESTful API for social media operations
+- ✅ Post management (Create, Read, Update, Delete)
+- ✅ Comment system with full CRUD operations
+- ✅ Like/Unlike functionality
+- ✅ SQLite database with JPA/Hibernate
+- ✅ OpenAPI/Swagger documentation
+- ✅ CORS enabled for localhost and GitHub Codespaces
+- ✅ Dynamic server URL configuration
+- ✅ Health check endpoints
+- ✅ Spring Boot Actuator integration
+- ✅ Comprehensive error handling
+- ✅ Input validation with Bean Validation
+
+## Quick Start
+
+### Prerequisites
Refer to the [README](../../README.md) doc for preparation.
-## Getting Started
+### 1. Environment Setup
+
+First, set the environment variable of `$REPOSITORY_ROOT`.
+
+```bash
+# bash/zsh
+REPOSITORY_ROOT=$(git rev-parse --show-toplevel)
+```
+
+```powershell
+# PowerShell
+$REPOSITORY_ROOT = git rev-parse --show-toplevel
+```
+
+Then, navigate to the java directory.
+
+```bash
+cd $REPOSITORY_ROOT/complete/java
+```
+
+### 2. Build the Application
+
+```bash
+# Make gradlew executable (if needed)
+chmod +x ./gradlew
+
+# Build the project
+./gradlew build
+```
+
+### 3. Run the Application
+
+```bash
+# Start the application using Gradle
+./gradlew bootRun
+
+# Alternative: Run the JAR file directly
+# java -jar build/libs/socialapp-0.0.1-SNAPSHOT.jar
+```
+
+### 4. Verify Application is Running
+
+```bash
+# Check health endpoint
+curl http://localhost:8080/api/health
+
+# Expected response: {"status":"healthy"}
+```
+
+### 5. Access API Documentation
+
+Open your browser and navigate to:
+
+- **Swagger UI**: [http://localhost:8080/swagger-ui.html](http://localhost:8080/swagger-ui.html)
+- **OpenAPI JSON**: [http://localhost:8080/v3/api-docs](http://localhost:8080/v3/api-docs)
+
+## API Endpoints
+
+### Health & Welcome
+
+- `GET /api/health` - Custom health check endpoint
+- `GET /api/welcome` - Welcome message endpoint
+
+### Posts Management
+
+- `GET /api/posts` - Get all posts
+- `GET /api/posts/{id}` - Get specific post by ID
+- `POST /api/posts` - Create a new post
+- `PATCH /api/posts/{id}` - Update an existing post
+- `DELETE /api/posts/{id}` - Delete a post
+
+### Comments Management
+
+- `GET /api/posts/{postId}/comments` - Get all comments for a post
+- `GET /api/posts/{postId}/comments/{commentId}` - Get specific comment
+- `POST /api/posts/{postId}/comments` - Add a comment to a post
+- `PATCH /api/posts/{postId}/comments/{commentId}` - Update a comment
+- `DELETE /api/posts/{postId}/comments/{commentId}` - Delete a comment
+
+### Likes Management
+
+- `POST /api/posts/{postId}/like` - Like a post
+- `DELETE /api/posts/{postId}/like` - Unlike a post
+
+### Spring Boot Actuator
+
+- `GET /actuator/health` - Spring Boot health indicator
+- `GET /actuator/info` - Application information
+
+## Testing the API
+
+### Using cURL Examples
+
+#### Create a Post
+
+```bash
+curl -X POST http://localhost:8080/api/posts \
+ -H "Content-Type: application/json" \
+ -d '{
+ "title": "My First Post",
+ "content": "This is the content of my first post!",
+ "authorName": "John Doe"
+ }'
+```
+
+#### Get All Posts
+
+```bash
+curl http://localhost:8080/api/posts
+```
+
+#### Add a Comment
+
+```bash
+curl -X POST http://localhost:8080/api/posts/1/comments \
+ -H "Content-Type: application/json" \
+ -d '{
+ "content": "Great post!",
+ "authorName": "Jane Smith"
+ }'
+```
+
+#### Like a Post
+
+```bash
+curl -X POST http://localhost:8080/api/posts/1/like \
+ -H "Content-Type: application/json" \
+ -d '{
+ "userName": "john_doe"
+ }'
+```
+
+### Using Swagger UI
+
+1. Open [http://localhost:8080/swagger-ui.html](http://localhost:8080/swagger-ui.html)
+2. Explore available endpoints
+3. Click "Try it out" on any endpoint
+4. Fill in parameters and click "Execute"
+
+## Development
+
+### Running Tests
+
+```bash
+# Run all tests
+./gradlew test
+
+# Run with coverage report
+./gradlew test jacocoTestReport
+
+# Run specific test class
+./gradlew test --tests "SocialAppApplicationTests"
+```
+
+### Database
+
+The application uses SQLite as an embedded database:
+
+- **Database file**: `sns_api.db` (created automatically)
+- **Location**: Project root directory
+- **Schema**: Auto-generated by Hibernate
+- **Sample data**: Loaded from `data.sql` (if present)
+
+To reset the database, simply delete the `sns_api.db` file and restart the application.
+
+## Configuration
+
+### Application Properties
+
+Key configuration settings in `application.properties`:
+
+```properties
+# Application Settings
+spring.application.name=socialapp
+server.port=8080
+
+# Database Configuration
+spring.datasource.url=jdbc:sqlite:sns_api.db
+spring.jpa.hibernate.ddl-auto=update
+
+# OpenAPI/Swagger Configuration
+springdoc.swagger-ui.path=/swagger-ui.html
+springdoc.swagger-ui.operationsSorter=method
+```
+
+### CORS Configuration
+
+The application supports both localhost and GitHub Codespaces:
+
+- **Localhost**: `http://localhost:8080`
+- **GitHub Codespaces**: Auto-detected and configured dynamically
+
+### Environment Detection
+
+The application automatically detects the runtime environment:
+
+- **Local Development**: Uses `http://localhost:8080`
+- **GitHub Codespaces**: Uses `https://{codespace-name}-8080.{domain}`
+
+## Deployment
+
+### Building for Production
+
+```bash
+# Create production JAR
+./gradlew clean build
+
+# JAR location
+ls -la build/libs/socialapp-0.0.1-SNAPSHOT.jar
+```
+
+### Running in Production
+
+```bash
+# Run with production profile
+java -jar build/libs/socialapp-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
+
+# Or with custom port
+java -jar build/libs/socialapp-0.0.1-SNAPSHOT.jar --server.port=8081
+```
+
+## Troubleshooting
+
+### Common Issues
+
+#### Port Already in Use
+
+```bash
+# Find process using port 8080
+lsof -i :8080
+
+# Kill the process (replace PID)
+kill -9
+
+# Or use a different port
+./gradlew bootRun --args='--server.port=8081'
+```
+
+#### Build Failures
+
+```bash
+# Clean and rebuild
+./gradlew clean build
+
+# Update Gradle wrapper
+./gradlew wrapper --gradle-version=8.5
+```
+
+#### Database Issues
+
+```bash
+# Reset database
+rm sns_api.db
+./gradlew bootRun
+```
+
+### Logs and Monitoring
+
+- **Application logs**: Console output when running `./gradlew bootRun`
+- **Health check**: `GET /actuator/health`
+- **Application info**: `GET /actuator/info`
+
+## Security Considerations
+
+⚠️ **Development Configuration**: The current setup is optimized for development with:
+
+- CORS enabled for all origins
+- SQLite database (not suitable for production scale)
+- No authentication/authorization
+
+For production deployment, consider:
-### Run Spring Boot Backend
+- Restricting CORS to specific domains
+- Using PostgreSQL/MySQL instead of SQLite
+- Implementing Spring Security for authentication
+- Adding rate limiting and input sanitization
+- Using HTTPS/TLS encryption
diff --git a/complete/java/demo/.github/code-instructions.md b/complete/java/demo/.github/code-instructions.md
deleted file mode 100644
index ff7c11b..0000000
--- a/complete/java/demo/.github/code-instructions.md
+++ /dev/null
@@ -1,112 +0,0 @@
-# Spring Boot Java Development Code Generation Guide (VSCode + GitHub Copilot)
-
-This document defines code generation rules and guidelines to follow when developing Spring Boot-based Java projects using GitHub Copilot in VSCode.
-
----
-
-## 1. Basic Configuration
-
-- **Language**: Java 17 or higher
-- **Framework**: Spring Boot 3.x
-- **Build Tool**: Gradle (Kotlin DSL) or Maven
-- **Project Structure**: Use standard `src/main/java`, `src/main/resources` structure
-- **Naming Convention**: Classes and methods use `CamelCase`, variables use `lowerCamelCase`
-
----
-
-## 2. Package Structure Example
-
-```
-com.example.project
-├── controller // REST controllers
-├── service // Business logic
-├── repository // JPA repositories
-├── domain // Entities and domain models
-├── dto // Data Transfer Objects
-├── config // Configuration classes
-├── exception // Custom exceptions and handlers
-```
-
----
-
-## 3. Code Style
-
-- Use **constructor injection** (`@RequiredArgsConstructor`)
-- Remove boilerplate with **Lombok**:
- - `@Getter`, `@Setter`, `@ToString`, `@NoArgsConstructor`, `@AllArgsConstructor`
-- Write **JavaDoc for all public classes and methods**
-- Handle logs with `@Slf4j`
-
----
-
-## 4. REST API Writing Rules
-
-- Use `@RestController`, `@RequestMapping`
-- Wrap responses with `ResponseEntity<>`
-- Use `@Valid` with `@RequestBody` for request body validation
-- Handle exceptions globally with `@ControllerAdvice`
-
----
-
-## 5. JPA Writing Rules
-
-- Required use of `@Entity`, `@Table`, `@Id`
-- Prefer `@ManyToOne(fetch = FetchType.LAZY)`
-- Use bidirectional mapping only when necessary
-- Don't return entities directly; use DTOs
-
----
-
-## 6. Testing Rules
-
-- Integration tests: `@SpringBootTest`, controller tests: `@WebMvcTest`
-- Dependency mocking: `@MockBean` or `Mockito`
-- Test class naming: `ClassName + Test.java`
-- Tests should be located under `src/test/java`
-
----
-
-## 7. Copilot Usage Tips
-
-- Use comments in the format `// Generate: description` to command Copilot
-- Writing just method signatures or class declarations can induce auto-completion
-- Always **refactor and review** generated code
-- Use inline comments for required logic flow (e.g., `// Check if user exists`)
-
----
-
-## Example Prompts
-
-```java
-// Generate: REST controller that manages User entities
-```
-
-```java
-// Generate: Service method that finds user by email
-```
-
----
-
-## 8. Dependency Configuration Example (Gradle Kotlin DSL)
-
-```kotlin
-dependencies {
- implementation("org.springframework.boot:spring-boot-starter-web")
- implementation("org.springframework.boot:spring-boot-starter-data-jpa")
- implementation("org.springframework.boot:spring-boot-starter-validation")
- implementation("org.projectlombok:lombok")
- runtimeOnly("com.h2database:h2") // or your DBMS in use
- testImplementation("org.springframework.boot:spring-boot-starter-test")
-}
-```
-
----
-
-## 9. API Documentation
-
-- Use `springdoc-openapi` for Swagger (OpenAPI) documentation
-- Use `@Operation` in controllers, `@Schema` in DTOs or entities
-
----
-
-**End.**
diff --git a/complete/java/demo/.github/copilot-instructions.md b/complete/java/demo/.github/copilot-instructions.md
deleted file mode 100644
index bbf7972..0000000
--- a/complete/java/demo/.github/copilot-instructions.md
+++ /dev/null
@@ -1 +0,0 @@
-- @azure Rule - Use Azure Best Practices: When generating code for Azure, running terminal commands for Azure, or performing operations related to Azure, invoke your `azure_development-get_best_practices` tool if available.
\ No newline at end of file
diff --git a/complete/java/demo/diagram.md b/complete/java/demo/diagram.md
deleted file mode 100644
index c424ad7..0000000
--- a/complete/java/demo/diagram.md
+++ /dev/null
@@ -1,138 +0,0 @@
-# SNS API Service Diagram
-
-## Overall System Architecture
-
-```
-+--------------------------------------------+
-| FastAPI Application |
-+--------------------------------------------+
-| - title: "Simple SNS API" |
-| - CORS middleware added |
-| - API router configuration (/api) |
-+--------------------------------------------+
- |
- | uses
- v
-+--------------------------------------------+
-| Database |
-+--------------------------------------------+
-| - SQLite |
-| - Tables: posts, comments, likes |
-+--------------------------------------------+
- |
- | contains
- v
-+--------------------------------------------+
-| Data Models |
-+--------------------------------------------+
-| | | | |
-| v v v |
-+----------+ +------+ +------+ |
-| Post | |Comment| | Like | |
-+----------+ +------+ +------+ |
-+--------------------------------------------+
- |
- | implements
- v
-+--------------------------------------------+
-| API Endpoints |
-+--------------------------------------------+
-| Post-related: |
-| - GET /api/posts |
-| - POST /api/posts |
-| - GET /api/posts/{postId} |
-| - PATCH /api/posts/{postId} |
-| - DELETE /api/posts/{postId} |
-| |
-| Comment-related: |
-| - GET /api/posts/{postId}/comments |
-| - POST /api/posts/{postId}/comments |
-| - GET /api/posts/{postId}/comments/{id} |
-| - PATCH /api/posts/{postId}/comments/{id} |
-| - DELETE /api/posts/{postId}/comments/{id}|
-| |
-| Like-related: |
-| - POST /api/posts/{postId}/likes |
-| - DELETE /api/posts/{postId}/likes |
-+--------------------------------------------+
-```
-
-## Detailed Data Model Diagram
-
-```
-+----------------+ +----------------+ +----------------+
-| Post | | Comment | | Like |
-+----------------+ +----------------+ +----------------+
-| id: int | | id: int | | postId: int |
-| userName: str | | postId: int | | userName: str |
-| content: str | | userName: str | | |
-| createdAt: str | | content: str | | |
-| updatedAt: str | | createdAt: str | | |
-| likeCount: int | | updatedAt: str | | |
-| commentCount:int| | | | |
-+----------------+ +----------------+ +----------------+
- | | |
- | 1 | * | *
- v v v
-+----------------+ +----------------+ +----------------+
-| PostCreate | | CommentCreate | | LikeBase |
-+----------------+ +----------------+ +----------------+
-| userName: str | | userName: str | | userName: str |
-| content: str | | content: str | | |
-+----------------+ +----------------+ +----------------+
- | |
- v v
-+----------------+ +----------------+
-| PostUpdate | | CommentUpdate |
-+----------------+ +----------------+
-| content: str | | content: str |
-+----------------+ +----------------+
-```
-
-## Database Schema Diagram
-
-```
-+----------------+ +----------------+ +----------------+
-| posts | | comments | | likes |
-+----------------+ +----------------+ +----------------+
-| id | | id | | postId |
-| userName | | postId | | userName |
-| content | | userName | +----------------+
-| createdAt | | content | ^
-| updatedAt | | createdAt | |
-| likeCount | | updatedAt | |
-| commentCount | +----------------+ |
-+----------------+ ^ |
- ^ | |
- | +------------------------+
- | | |
- +-----------------------+------------------------+
- Foreign Key Relationships
-```
-
-## API Flow Diagram
-
-```
-Client → HTTP Request → FastAPI Application → API Router → Business Logic →
-SQLite Database → Results → Pydantic Model Conversion → JSON Response → Client
-```
-
-## API Endpoint Descriptions
-
-1. **Post-related APIs**
- - `GET /api/posts`: Retrieve all posts
- - `POST /api/posts`: Create a new post
- - `GET /api/posts/{postId}`: Retrieve a specific post
- - `PATCH /api/posts/{postId}`: Update a specific post
- - `DELETE /api/posts/{postId}`: Delete a specific post
-
-2. **Comment-related APIs**
- - `GET /api/posts/{postId}/comments`: Retrieve comments for a specific post
- - `POST /api/posts/{postId}/comments`: Create a comment on a specific post
- - `GET /api/posts/{postId}/comments/{commentId}`: Retrieve a specific comment
- - `PATCH /api/posts/{postId}/comments/{commentId}`: Update a specific comment
- - `DELETE /api/posts/{postId}/comments/{commentId}`: Delete a specific comment
-
-3. **Like-related APIs**
- - `POST /api/posts/{postId}/likes`: Add a like to a specific post
- - `DELETE /api/posts/{postId}/likes`: Remove a like from a specific post
diff --git a/complete/java/demo/settings.gradle b/complete/java/demo/settings.gradle
deleted file mode 100644
index 0a383dd..0000000
--- a/complete/java/demo/settings.gradle
+++ /dev/null
@@ -1 +0,0 @@
-rootProject.name = 'demo'
diff --git a/complete/java/demo/src/main/java/com/example/demo/DemoApplication.java b/complete/java/demo/src/main/java/com/example/demo/DemoApplication.java
deleted file mode 100644
index 64b538a..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/DemoApplication.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.example.demo;
-
-import org.springframework.boot.SpringApplication;
-import org.springframework.boot.autoconfigure.SpringBootApplication;
-
-@SpringBootApplication
-public class DemoApplication {
-
- public static void main(String[] args) {
- SpringApplication.run(DemoApplication.class, args);
- }
-
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/config/OpenApiConfig.java b/complete/java/demo/src/main/java/com/example/demo/config/OpenApiConfig.java
deleted file mode 100644
index 1ba1b95..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/config/OpenApiConfig.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.example.demo.config;
-
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-
-import io.swagger.v3.oas.models.OpenAPI;
-import io.swagger.v3.oas.models.info.Contact;
-import io.swagger.v3.oas.models.info.Info;
-import io.swagger.v3.oas.models.info.License;
-import io.swagger.v3.oas.models.Components;
-import io.swagger.v3.oas.models.security.SecurityScheme;
-
-@Configuration
-public class OpenApiConfig {
-
- @Bean
- public OpenAPI customOpenAPI() {
- return new OpenAPI()
- .info(new Info()
- .title("Demo API")
- .description("Spring Boot 데모 애플리케이션 API 문서")
- .version("v1.0.0")
- .contact(new Contact()
- .name("Demo Team")
- .email("demo@example.com"))
- .license(new License()
- .name("Apache 2.0")
- .url("https://www.apache.org/licenses/LICENSE-2.0")))
- .components(new Components()
- .addSecuritySchemes("bearer-key",
- new SecurityScheme()
- .type(SecurityScheme.Type.HTTP)
- .scheme("bearer")
- .bearerFormat("JWT")));
- }
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/config/WebConfig.java b/complete/java/demo/src/main/java/com/example/demo/config/WebConfig.java
deleted file mode 100644
index f31374f..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/config/WebConfig.java
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.example.demo.config;
-
-import org.springframework.context.annotation.Configuration;
-import org.springframework.web.servlet.config.annotation.CorsRegistry;
-import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
-
-@Configuration
-public class WebConfig implements WebMvcConfigurer {
-
- @Override
- public void addCorsMappings(CorsRegistry registry) {
- registry.addMapping("/**")
- .allowedOrigins("*")
- .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
- .allowedHeaders("*")
- .maxAge(3600);
- }
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/controller/CommentController.java b/complete/java/demo/src/main/java/com/example/demo/controller/CommentController.java
deleted file mode 100644
index 5ab8b79..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/controller/CommentController.java
+++ /dev/null
@@ -1,131 +0,0 @@
-// filepath: /workspaces/github-copilot-bootcamp-2025-main/java/demo/src/main/java/com/example/demo/controller/CommentController.java
-package com.example.demo.controller;
-
-import com.example.demo.dto.CommentCreateDto;
-import com.example.demo.dto.CommentUpdateDto;
-import com.example.demo.model.Comment;
-import com.example.demo.service.CommentService;
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.Parameter;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import io.swagger.v3.oas.annotations.responses.ApiResponse;
-import io.swagger.v3.oas.annotations.responses.ApiResponses;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.*;
-
-import java.util.List;
-import java.util.Optional;
-
-@RestController
-@RequestMapping("/api/posts/{postId}/comments")
-@Tag(name = "댓글 관리", description = "댓글 조회, 생성, 수정, 삭제 API")
-public class CommentController {
-
- private final CommentService commentService;
-
- @Autowired
- public CommentController(CommentService commentService) {
- this.commentService = commentService;
- }
-
- @Operation(summary = "포스트 댓글 목록 조회", description = "특정 포스트에 작성된 모든 댓글을 조회합니다.")
- @ApiResponses(value = {
- @ApiResponse(responseCode = "200", description = "댓글 목록 조회 성공")
- })
- @GetMapping
- public ResponseEntity> getCommentsByPostId(
- @Parameter(description = "포스트 ID", required = true) @PathVariable Long postId) {
- List comments = commentService.getCommentsByPostId(postId);
- return ResponseEntity.ok(comments);
- }
-
- @Operation(summary = "댓글 작성", description = "특정 포스트에 새 댓글을 작성합니다.")
- @ApiResponses(value = {
- @ApiResponse(responseCode = "201", description = "댓글 작성 성공")
- })
- @PostMapping
- public ResponseEntity createComment(
- @Parameter(description = "포스트 ID", required = true) @PathVariable Long postId,
- @Parameter(description = "댓글 작성 정보", required = true) @RequestBody CommentCreateDto commentCreateDto) {
- Comment createdComment = commentService.createComment(postId, commentCreateDto);
- return ResponseEntity.status(HttpStatus.CREATED).body(createdComment);
- }
-
- @Operation(summary = "댓글 상세 조회", description = "특정 댓글의 상세 정보를 조회합니다.")
- @ApiResponses(value = {
- @ApiResponse(responseCode = "200", description = "댓글 조회 성공"),
- @ApiResponse(responseCode = "404", description = "댓글을 찾을 수 없음")
- })
- @GetMapping("/{commentId}")
- public ResponseEntity getCommentById(
- @Parameter(description = "포스트 ID", required = true) @PathVariable Long postId,
- @Parameter(description = "댓글 ID", required = true) @PathVariable Long commentId) {
- Optional commentOptional = commentService.getCommentById(commentId);
-
- if (!commentOptional.isPresent()) {
- return new ResponseEntity<>(HttpStatus.NOT_FOUND);
- }
-
- Comment comment = commentOptional.get();
- if (!comment.getPostId().equals(postId)) {
- return new ResponseEntity<>(HttpStatus.NOT_FOUND);
- }
-
- return ResponseEntity.ok(comment);
- }
-
- @Operation(summary = "댓글 수정", description = "특정 댓글의 내용을 수정합니다.")
- @ApiResponses(value = {
- @ApiResponse(responseCode = "200", description = "댓글 수정 성공"),
- @ApiResponse(responseCode = "404", description = "댓글을 찾을 수 없음")
- })
- @PatchMapping("/{commentId}")
- public ResponseEntity updateComment(
- @Parameter(description = "포스트 ID", required = true) @PathVariable Long postId,
- @Parameter(description = "댓글 ID", required = true) @PathVariable Long commentId,
- @Parameter(description = "댓글 수정 정보", required = true) @RequestBody CommentUpdateDto commentUpdateDto) {
- Optional commentOptional = commentService.getCommentById(commentId);
-
- if (!commentOptional.isPresent()) {
- return new ResponseEntity<>(HttpStatus.NOT_FOUND);
- }
-
- Comment comment = commentOptional.get();
- if (!comment.getPostId().equals(postId)) {
- return new ResponseEntity<>(HttpStatus.NOT_FOUND);
- }
-
- Optional updatedCommentOptional = commentService.updateComment(commentId, commentUpdateDto);
- if (!updatedCommentOptional.isPresent()) {
- return new ResponseEntity<>(HttpStatus.NOT_FOUND);
- }
-
- return ResponseEntity.ok(updatedCommentOptional.get());
- }
-
- @Operation(summary = "댓글 삭제", description = "특정 댓글을 삭제합니다.")
- @ApiResponses(value = {
- @ApiResponse(responseCode = "204", description = "댓글 삭제 성공"),
- @ApiResponse(responseCode = "404", description = "댓글을 찾을 수 없음")
- })
- @DeleteMapping("/{commentId}")
- public ResponseEntity deleteComment(
- @Parameter(description = "포스트 ID", required = true) @PathVariable Long postId,
- @Parameter(description = "댓글 ID", required = true) @PathVariable Long commentId) {
- Optional commentOptional = commentService.getCommentById(commentId);
-
- if (!commentOptional.isPresent()) {
- return new ResponseEntity<>(HttpStatus.NOT_FOUND);
- }
-
- Comment comment = commentOptional.get();
- if (!comment.getPostId().equals(postId)) {
- return new ResponseEntity<>(HttpStatus.NOT_FOUND);
- }
-
- commentService.deleteComment(commentId);
- return ResponseEntity.noContent().build();
- }
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/controller/HelloController.java b/complete/java/demo/src/main/java/com/example/demo/controller/HelloController.java
deleted file mode 100644
index c03b2d5..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/controller/HelloController.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.example.demo.controller;
-
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.Parameter;
-import io.swagger.v3.oas.annotations.responses.ApiResponse;
-import io.swagger.v3.oas.annotations.tags.Tag;
-
-@RestController
-@Tag(name = "Hello API", description = "인사말을 제공하는 API")
-public class HelloController {
-
- @GetMapping("/hello")
- @Operation(
- summary = "인사말 메시지 반환",
- description = "이름을 입력받아 맞춤형 인사말을 반환합니다"
- )
- @ApiResponse(responseCode = "200", description = "성공적으로 인사말 반환")
- public String hello(
- @Parameter(description = "인사할 대상의 이름", example = "World")
- @RequestParam(value = "name", defaultValue = "World") String name) {
-
- return String.format("Hello, %s!", name);
- }
-
-
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/controller/LikeController.java b/complete/java/demo/src/main/java/com/example/demo/controller/LikeController.java
deleted file mode 100644
index 49b2e1a..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/controller/LikeController.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// filepath: /workspaces/github-copilot-bootcamp-2025-main/java/demo/src/main/java/com/example/demo/controller/LikeController.java
-package com.example.demo.controller;
-
-import com.example.demo.dto.LikeBaseDto;
-import com.example.demo.model.Like;
-import com.example.demo.service.LikeService;
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.Parameter;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import io.swagger.v3.oas.annotations.responses.ApiResponse;
-import io.swagger.v3.oas.annotations.responses.ApiResponses;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.*;
-
-@RestController
-@RequestMapping("/api/posts/{postId}/likes")
-@Tag(name = "좋아요 관리", description = "포스트 좋아요 추가 및 취소 API")
-public class LikeController {
-
- private final LikeService likeService;
-
- @Autowired
- public LikeController(LikeService likeService) {
- this.likeService = likeService;
- }
-
- // 특정 포스트에 좋아요 추가
- @Operation(summary = "좋아요 추가", description = "특정 포스트에 좋아요를 추가합니다.")
- @ApiResponses(value = {
- @ApiResponse(responseCode = "201", description = "좋아요 추가 성공"),
- @ApiResponse(responseCode = "400", description = "잘못된 요청")
- })
- @PostMapping
- public ResponseEntity addLike(
- @Parameter(description = "포스트 ID", required = true) @PathVariable Long postId,
- @Parameter(description = "좋아요 정보", required = true) @RequestBody LikeBaseDto likeBaseDto) {
- Like like = likeService.addLike(postId, likeBaseDto);
- return ResponseEntity.status(HttpStatus.CREATED).body(like);
- }
-
- // 특정 포스트의 좋아요 취소
- @Operation(summary = "좋아요 취소", description = "특정 포스트의 좋아요를 취소합니다.")
- @ApiResponses(value = {
- @ApiResponse(responseCode = "204", description = "좋아요 취소 성공"),
- @ApiResponse(responseCode = "404", description = "좋아요를 찾을 수 없음")
- })
- @DeleteMapping
- public ResponseEntity removeLike(
- @Parameter(description = "포스트 ID", required = true) @PathVariable Long postId,
- @Parameter(description = "사용자 이름", required = true) @RequestParam String userName) {
- if (likeService.hasLiked(postId, userName)) {
- likeService.removeLike(postId, userName);
- return ResponseEntity.noContent().build();
- } else {
- return ResponseEntity.notFound().build();
- }
- }
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/controller/PostController.java b/complete/java/demo/src/main/java/com/example/demo/controller/PostController.java
deleted file mode 100644
index 814f072..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/controller/PostController.java
+++ /dev/null
@@ -1,73 +0,0 @@
-package com.example.demo.controller;
-
-import com.example.demo.dto.PostCreateDto;
-import com.example.demo.dto.PostUpdateDto;
-import com.example.demo.model.Post;
-import com.example.demo.service.PostService;
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.Parameter;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import io.swagger.v3.oas.annotations.responses.ApiResponse;
-import io.swagger.v3.oas.annotations.responses.ApiResponses;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.*;
-
-import java.util.List;
-
-@RestController
-@RequestMapping("/api/posts")
-@Tag(name = "게시물 관리", description = "게시물 조회, 생성, 수정, 삭제 API")
-public class PostController {
-
- private final PostService postService;
-
- @Autowired
- public PostController(PostService postService) {
- this.postService = postService;
- }
-
- // 모든 포스트 목록 조회
- @GetMapping
- @Operation(summary = "모든 게시물 조회", description = "모든 게시물 목록을 조회합니다.")
- @ApiResponse(responseCode = "200", description = "성공적으로 게시물 목록 반환")
- public ResponseEntity> getAllPosts() {
- List posts = postService.getAllPosts();
- return ResponseEntity.ok(posts);
- }
-
- // 새 포스트 작성
- @PostMapping
- public ResponseEntity createPost(@RequestBody PostCreateDto postCreateDto) {
- Post createdPost = postService.createPost(postCreateDto);
- return ResponseEntity.status(HttpStatus.CREATED).body(createdPost);
- }
-
- // 특정 포스트 조회
- @GetMapping("/{postId}")
- public ResponseEntity getPostById(@PathVariable Long postId) {
- return postService.getPostById(postId)
- .map(ResponseEntity::ok)
- .orElse(ResponseEntity.notFound().build());
- }
-
- // 특정 포스트 수정
- @PatchMapping("/{postId}")
- public ResponseEntity updatePost(@PathVariable Long postId, @RequestBody PostUpdateDto postUpdateDto) {
- return postService.updatePost(postId, postUpdateDto)
- .map(ResponseEntity::ok)
- .orElse(ResponseEntity.notFound().build());
- }
-
- // 특정 포스트 삭제
- @DeleteMapping("/{postId}")
- public ResponseEntity deletePost(@PathVariable Long postId) {
- if (postService.getPostById(postId).isPresent()) {
- postService.deletePost(postId);
- return ResponseEntity.noContent().build();
- } else {
- return ResponseEntity.notFound().build();
- }
- }
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/dto/CommentCreateDto.java b/complete/java/demo/src/main/java/com/example/demo/dto/CommentCreateDto.java
deleted file mode 100644
index ce44b80..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/dto/CommentCreateDto.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.example.demo.dto;
-
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class CommentCreateDto {
- private String userName;
- private String content;
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/dto/CommentUpdateDto.java b/complete/java/demo/src/main/java/com/example/demo/dto/CommentUpdateDto.java
deleted file mode 100644
index 8e6c812..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/dto/CommentUpdateDto.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.example.demo.dto;
-
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class CommentUpdateDto {
- private String content;
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/dto/LikeBaseDto.java b/complete/java/demo/src/main/java/com/example/demo/dto/LikeBaseDto.java
deleted file mode 100644
index 74bd1c1..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/dto/LikeBaseDto.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.example.demo.dto;
-
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class LikeBaseDto {
- private String userName;
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/dto/PostCreateDto.java b/complete/java/demo/src/main/java/com/example/demo/dto/PostCreateDto.java
deleted file mode 100644
index 2fe43c3..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/dto/PostCreateDto.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package com.example.demo.dto;
-
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class PostCreateDto {
- private String userName;
- private String content;
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/dto/PostUpdateDto.java b/complete/java/demo/src/main/java/com/example/demo/dto/PostUpdateDto.java
deleted file mode 100644
index e55cd44..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/dto/PostUpdateDto.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.example.demo.dto;
-
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class PostUpdateDto {
- private String content;
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/model/Comment.java b/complete/java/demo/src/main/java/com/example/demo/model/Comment.java
deleted file mode 100644
index 925e696..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/model/Comment.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.example.demo.model;
-
-import jakarta.persistence.*;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-import java.time.LocalDateTime;
-
-@Data
-@Entity
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-@Table(name = "comments")
-public class Comment {
-
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
-
- private Long postId;
- private String userName;
- private String content;
-
- private LocalDateTime createdAt;
- private LocalDateTime updatedAt;
-
- @PrePersist
- protected void onCreate() {
- createdAt = LocalDateTime.now();
- updatedAt = LocalDateTime.now();
- }
-
- @PreUpdate
- protected void onUpdate() {
- updatedAt = LocalDateTime.now();
- }
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/model/Like.java b/complete/java/demo/src/main/java/com/example/demo/model/Like.java
deleted file mode 100644
index 346a7a2..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/model/Like.java
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.example.demo.model;
-
-import jakarta.persistence.*;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-@Data
-@Entity
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-@Table(name = "likes", uniqueConstraints = {
- @UniqueConstraint(columnNames = {"postId", "userName"})
-})
-public class Like {
-
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
-
- private Long postId;
- private String userName;
-
- // 한 사용자가 같은 포스트에 한 번만 좋아요 가능
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/model/Post.java b/complete/java/demo/src/main/java/com/example/demo/model/Post.java
deleted file mode 100644
index b1df105..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/model/Post.java
+++ /dev/null
@@ -1,41 +0,0 @@
-package com.example.demo.model;
-
-import jakarta.persistence.*;
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-import java.time.LocalDateTime;
-
-@Data
-@Entity
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-@Table(name = "posts")
-public class Post {
-
- @Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
-
- private String userName;
- private String content;
-
- private LocalDateTime createdAt;
- private LocalDateTime updatedAt;
-
- private int likeCount;
- private int commentCount;
-
- @PrePersist
- protected void onCreate() {
- createdAt = LocalDateTime.now();
- updatedAt = LocalDateTime.now();
- }
-
- @PreUpdate
- protected void onUpdate() {
- updatedAt = LocalDateTime.now();
- }
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/repository/CommentRepository.java b/complete/java/demo/src/main/java/com/example/demo/repository/CommentRepository.java
deleted file mode 100644
index 4ae9b49..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/repository/CommentRepository.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.example.demo.repository;
-
-import com.example.demo.model.Comment;
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.stereotype.Repository;
-import java.util.List;
-
-@Repository
-public interface CommentRepository extends JpaRepository {
- // 특정 포스트에 속한 댓글 목록 조회
- List findByPostId(Long postId);
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/repository/LikeRepository.java b/complete/java/demo/src/main/java/com/example/demo/repository/LikeRepository.java
deleted file mode 100644
index a3c6774..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/repository/LikeRepository.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.example.demo.repository;
-
-import com.example.demo.model.Like;
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.stereotype.Repository;
-import java.util.Optional;
-import java.util.List;
-
-@Repository
-public interface LikeRepository extends JpaRepository {
- // 특정 포스트의 좋아요 목록 조회
- List findByPostId(Long postId);
-
- // 특정 사용자가 특정 포스트에 좋아요 했는지 확인
- Optional findByPostIdAndUserName(Long postId, String userName);
-
- // 특정 포스트의 좋아요 수 계산
- long countByPostId(Long postId);
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/repository/PostRepository.java b/complete/java/demo/src/main/java/com/example/demo/repository/PostRepository.java
deleted file mode 100644
index 9ae6de2..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/repository/PostRepository.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.example.demo.repository;
-
-import com.example.demo.model.Post;
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.stereotype.Repository;
-
-@Repository
-public interface PostRepository extends JpaRepository {
- // 기본 CRUD 메소드는 JpaRepository에서 제공됨
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/service/CommentService.java b/complete/java/demo/src/main/java/com/example/demo/service/CommentService.java
deleted file mode 100644
index f533e7c..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/service/CommentService.java
+++ /dev/null
@@ -1,74 +0,0 @@
-package com.example.demo.service;
-
-import com.example.demo.dto.CommentCreateDto;
-import com.example.demo.dto.CommentUpdateDto;
-import com.example.demo.model.Comment;
-import com.example.demo.repository.CommentRepository;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
-import java.util.Optional;
-
-@Service
-public class CommentService {
-
- private final CommentRepository commentRepository;
- private final PostService postService;
-
- @Autowired
- public CommentService(CommentRepository commentRepository, PostService postService) {
- this.commentRepository = commentRepository;
- this.postService = postService;
- }
-
- // 특정 포스트의 모든 댓글 조회
- public List getCommentsByPostId(Long postId) {
- return commentRepository.findByPostId(postId);
- }
-
- // 특정 댓글 조회
- public Optional getCommentById(Long commentId) {
- return commentRepository.findById(commentId);
- }
-
- // 새 댓글 생성
- @Transactional
- public Comment createComment(Long postId, CommentCreateDto commentCreateDto) {
- Comment comment = Comment.builder()
- .postId(postId)
- .userName(commentCreateDto.getUserName())
- .content(commentCreateDto.getContent())
- .build();
-
- Comment savedComment = commentRepository.save(comment);
-
- // 포스트의 댓글 수 증가
- postService.updateCommentCount(postId, 1);
-
- return savedComment;
- }
-
- // 댓글 업데이트
- @Transactional
- public Optional updateComment(Long commentId, CommentUpdateDto commentUpdateDto) {
- return commentRepository.findById(commentId)
- .map(comment -> {
- comment.setContent(commentUpdateDto.getContent());
- return commentRepository.save(comment);
- });
- }
-
- // 댓글 삭제
- @Transactional
- public void deleteComment(Long commentId) {
- commentRepository.findById(commentId).ifPresent(comment -> {
- Long postId = comment.getPostId();
- commentRepository.deleteById(commentId);
-
- // 포스트의 댓글 수 감소
- postService.updateCommentCount(postId, -1);
- });
- }
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/service/LikeService.java b/complete/java/demo/src/main/java/com/example/demo/service/LikeService.java
deleted file mode 100644
index a82cce5..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/service/LikeService.java
+++ /dev/null
@@ -1,68 +0,0 @@
-package com.example.demo.service;
-
-import com.example.demo.dto.LikeBaseDto;
-import com.example.demo.model.Like;
-import com.example.demo.repository.LikeRepository;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-import java.util.Optional;
-
-@Service
-public class LikeService {
-
- private final LikeRepository likeRepository;
- private final PostService postService;
-
- @Autowired
- public LikeService(LikeRepository likeRepository, PostService postService) {
- this.likeRepository = likeRepository;
- this.postService = postService;
- }
-
- // 좋아요 추가
- @Transactional
- public Like addLike(Long postId, LikeBaseDto likeBaseDto) {
- Optional existingLike = likeRepository.findByPostIdAndUserName(postId, likeBaseDto.getUserName());
-
- if (existingLike.isPresent()) {
- // 이미 좋아요를 누른 경우
- return existingLike.get();
- }
-
- Like like = Like.builder()
- .postId(postId)
- .userName(likeBaseDto.getUserName())
- .build();
-
- Like savedLike = likeRepository.save(like);
-
- // 포스트의 좋아요 수 증가
- postService.updateLikeCount(postId, 1);
-
- return savedLike;
- }
-
- // 좋아요 취소
- @Transactional
- public void removeLike(Long postId, String userName) {
- Optional existingLike = likeRepository.findByPostIdAndUserName(postId, userName);
-
- existingLike.ifPresent(like -> {
- likeRepository.delete(like);
-
- // 포스트의 좋아요 수 감소
- postService.updateLikeCount(postId, -1);
- });
- }
-
- // 특정 사용자가 특정 포스트에 좋아요 했는지 확인
- public boolean hasLiked(Long postId, String userName) {
- return likeRepository.findByPostIdAndUserName(postId, userName).isPresent();
- }
-
- // 특정 포스트의 좋아요 수 계산
- public long countLikesByPostId(Long postId) {
- return likeRepository.countByPostId(postId);
- }
-}
diff --git a/complete/java/demo/src/main/java/com/example/demo/service/PostService.java b/complete/java/demo/src/main/java/com/example/demo/service/PostService.java
deleted file mode 100644
index 3314da6..0000000
--- a/complete/java/demo/src/main/java/com/example/demo/service/PostService.java
+++ /dev/null
@@ -1,80 +0,0 @@
-package com.example.demo.service;
-
-import com.example.demo.dto.PostCreateDto;
-import com.example.demo.dto.PostUpdateDto;
-import com.example.demo.model.Post;
-import com.example.demo.repository.PostRepository;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
-import java.util.Optional;
-
-@Service
-public class PostService {
-
- private final PostRepository postRepository;
-
- @Autowired
- public PostService(PostRepository postRepository) {
- this.postRepository = postRepository;
- }
-
- // 모든 포스트 조회
- public List getAllPosts() {
- return postRepository.findAll();
- }
-
- // 특정 포스트 조회
- public Optional getPostById(Long postId) {
- return postRepository.findById(postId);
- }
-
- // 새 포스트 생성
- @Transactional
- public Post createPost(PostCreateDto postCreateDto) {
- Post post = Post.builder()
- .userName(postCreateDto.getUserName())
- .content(postCreateDto.getContent())
- .likeCount(0)
- .commentCount(0)
- .build();
-
- return postRepository.save(post);
- }
-
- // 포스트 업데이트
- @Transactional
- public Optional updatePost(Long postId, PostUpdateDto postUpdateDto) {
- return postRepository.findById(postId)
- .map(post -> {
- post.setContent(postUpdateDto.getContent());
- return postRepository.save(post);
- });
- }
-
- // 포스트 삭제
- @Transactional
- public void deletePost(Long postId) {
- postRepository.deleteById(postId);
- }
-
- // 좋아요 수 업데이트
- @Transactional
- public void updateLikeCount(Long postId, int change) {
- postRepository.findById(postId).ifPresent(post -> {
- post.setLikeCount(post.getLikeCount() + change);
- postRepository.save(post);
- });
- }
-
- // 댓글 수 업데이트
- @Transactional
- public void updateCommentCount(Long postId, int change) {
- postRepository.findById(postId).ifPresent(post -> {
- post.setCommentCount(post.getCommentCount() + change);
- postRepository.save(post);
- });
- }
-}
diff --git a/complete/java/demo/src/main/resources/application.properties b/complete/java/demo/src/main/resources/application.properties
deleted file mode 100644
index 3d61560..0000000
--- a/complete/java/demo/src/main/resources/application.properties
+++ /dev/null
@@ -1,16 +0,0 @@
-spring.application.name=demo
-
-# 데이터베이스 설정
-spring.datasource.url=jdbc:sqlite:sns.db
-spring.datasource.driver-class-name=org.sqlite.JDBC
-spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect
-spring.jpa.hibernate.ddl-auto=update
-spring.jpa.show-sql=true
-
-# Swagger UI 설정
-springdoc.swagger-ui.path=/swagger-ui.html
-springdoc.api-docs.path=/api-docs
-springdoc.swagger-ui.operationsSorter=method
-springdoc.swagger-ui.tagsSorter=alpha
-springdoc.packages-to-scan=com.example.demo.controller
-springdoc.paths-to-match=/api/**,/hello
diff --git a/complete/java/demo/.gitattributes b/complete/java/socialapp/.gitattributes
similarity index 100%
rename from complete/java/demo/.gitattributes
rename to complete/java/socialapp/.gitattributes
diff --git a/complete/java/demo/.gitignore b/complete/java/socialapp/.gitignore
similarity index 100%
rename from complete/java/demo/.gitignore
rename to complete/java/socialapp/.gitignore
diff --git a/complete/java/demo/build.gradle b/complete/java/socialapp/build.gradle
similarity index 76%
rename from complete/java/demo/build.gradle
rename to complete/java/socialapp/build.gradle
index 46dc1f6..2c89622 100644
--- a/complete/java/demo/build.gradle
+++ b/complete/java/socialapp/build.gradle
@@ -1,10 +1,10 @@
plugins {
id 'java'
- id 'org.springframework.boot' version '3.4.4'
+ id 'org.springframework.boot' version '3.2.5'
id 'io.spring.dependency-management' version '1.1.7'
}
-group = 'com.example'
+group = 'com.contoso'
version = '0.0.1-SNAPSHOT'
java {
@@ -24,17 +24,14 @@ repositories {
}
dependencies {
- implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ implementation platform("org.springframework.boot:spring-boot-dependencies:3.2.5")
implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
-
- // Swagger UI
- implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'
-
- // SQLite Database
- implementation 'org.xerial:sqlite-jdbc:3.45.1.0'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+ implementation 'org.xerial:sqlite-jdbc:3.45.0.0'
implementation 'org.hibernate.orm:hibernate-community-dialects:6.4.4.Final'
-
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
diff --git a/complete/java/demo/gradle/wrapper/gradle-wrapper.jar b/complete/java/socialapp/gradle/wrapper/gradle-wrapper.jar
similarity index 93%
rename from complete/java/demo/gradle/wrapper/gradle-wrapper.jar
rename to complete/java/socialapp/gradle/wrapper/gradle-wrapper.jar
index 9bbc975..1b33c55 100644
Binary files a/complete/java/demo/gradle/wrapper/gradle-wrapper.jar and b/complete/java/socialapp/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/complete/java/demo/gradle/wrapper/gradle-wrapper.properties b/complete/java/socialapp/gradle/wrapper/gradle-wrapper.properties
similarity index 94%
rename from complete/java/demo/gradle/wrapper/gradle-wrapper.properties
rename to complete/java/socialapp/gradle/wrapper/gradle-wrapper.properties
index 37f853b..ca025c8 100644
--- a/complete/java/demo/gradle/wrapper/gradle-wrapper.properties
+++ b/complete/java/socialapp/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/complete/java/demo/gradlew b/complete/java/socialapp/gradlew
similarity index 98%
rename from complete/java/demo/gradlew
rename to complete/java/socialapp/gradlew
index faf9300..23d15a9 100755
--- a/complete/java/demo/gradlew
+++ b/complete/java/socialapp/gradlew
@@ -114,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
- org.gradle.wrapper.GradleWrapperMain \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
diff --git a/complete/java/demo/gradlew.bat b/complete/java/socialapp/gradlew.bat
similarity index 94%
rename from complete/java/demo/gradlew.bat
rename to complete/java/socialapp/gradlew.bat
index 9d21a21..db3a6ac 100644
--- a/complete/java/demo/gradlew.bat
+++ b/complete/java/socialapp/gradlew.bat
@@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+set CLASSPATH=
@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
diff --git a/complete/java/socialapp/settings.gradle b/complete/java/socialapp/settings.gradle
new file mode 100644
index 0000000..4cb1bbd
--- /dev/null
+++ b/complete/java/socialapp/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'socialapp'
diff --git a/complete/java/socialapp/src/main/java/com/contoso/socialapp/SocialAppApplication.java b/complete/java/socialapp/src/main/java/com/contoso/socialapp/SocialAppApplication.java
new file mode 100644
index 0000000..df43c4f
--- /dev/null
+++ b/complete/java/socialapp/src/main/java/com/contoso/socialapp/SocialAppApplication.java
@@ -0,0 +1,17 @@
+package com.contoso.socialapp;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Main application class for Contoso Social App.
+ * This Spring Boot application provides a social media platform backend.
+ */
+@SpringBootApplication
+public class SocialAppApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(SocialAppApplication.class, args);
+ }
+
+}
diff --git a/complete/java/socialapp/src/main/java/com/contoso/socialapp/config/OpenApiConfig.java b/complete/java/socialapp/src/main/java/com/contoso/socialapp/config/OpenApiConfig.java
new file mode 100644
index 0000000..782c087
--- /dev/null
+++ b/complete/java/socialapp/src/main/java/com/contoso/socialapp/config/OpenApiConfig.java
@@ -0,0 +1,55 @@
+package com.contoso.socialapp.config;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Contact;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.info.License;
+import io.swagger.v3.oas.models.servers.Server;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Configuration
+public class OpenApiConfig {
+
+ @Value("${server.port:8080}")
+ private String serverPort;
+
+ @Bean
+ public OpenAPI customOpenAPI() {
+ List servers = new ArrayList<>();
+
+ // Detect GitHub Codespaces environment
+ String codespaceName = System.getenv("CODESPACE_NAME");
+ String githubCodespacesPortForwardingDomain = System.getenv("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN");
+
+ if (codespaceName != null && githubCodespacesPortForwardingDomain != null) {
+ // GitHub Codespaces environment
+ String codespaceUrl = "https://" + codespaceName + "-" + serverPort + "." + githubCodespacesPortForwardingDomain;
+ servers.add(new Server()
+ .url(codespaceUrl)
+ .description("GitHub Codespaces server"));
+ }
+
+ // Always add localhost server
+ servers.add(new Server()
+ .url("http://localhost:" + serverPort)
+ .description("Local development server"));
+
+ return new OpenAPI()
+ .info(new Info()
+ .title("Simple Social Media API")
+ .description("A basic Social Networking Service (SNS) API that allows users to create, retrieve, update, and delete posts; add comments; and like/unlike posts.")
+ .version("1.0.0")
+ .contact(new Contact()
+ .name("Contoso Product Team")
+ .email("support@contoso.com"))
+ .license(new License()
+ .name("MIT")
+ .url("https://opensource.org/licenses/MIT")))
+ .servers(servers);
+ }
+}
diff --git a/complete/java/socialapp/src/main/java/com/contoso/socialapp/config/WebConfig.java b/complete/java/socialapp/src/main/java/com/contoso/socialapp/config/WebConfig.java
new file mode 100644
index 0000000..9887dfa
--- /dev/null
+++ b/complete/java/socialapp/src/main/java/com/contoso/socialapp/config/WebConfig.java
@@ -0,0 +1,32 @@
+package com.contoso.socialapp.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Web configuration class to handle CORS settings and other web-related configurations.
+ */
+@Configuration
+public class WebConfig {
+
+ /**
+ * Configure CORS to allow requests from all origins.
+ * This is suitable for development but should be restricted in production.
+ */
+ @Bean
+ public WebMvcConfigurer corsConfigurer() {
+ return new WebMvcConfigurer() {
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/**")
+ .allowedOriginPatterns("http://localhost:8080", "http://contoso-backend:8080", "https://*.app.github.dev")
+ .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH")
+ .allowedHeaders("*")
+ .allowCredentials(true)
+ .maxAge(3600);
+ }
+ };
+ }
+}
diff --git a/complete/java/socialapp/src/main/java/com/contoso/socialapp/controller/CommentController.java b/complete/java/socialapp/src/main/java/com/contoso/socialapp/controller/CommentController.java
new file mode 100644
index 0000000..dabd1dd
--- /dev/null
+++ b/complete/java/socialapp/src/main/java/com/contoso/socialapp/controller/CommentController.java
@@ -0,0 +1,142 @@
+package com.contoso.socialapp.controller;
+
+import com.contoso.socialapp.dto.*;
+import com.contoso.socialapp.service.CommentService;
+import com.contoso.socialapp.service.PostService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/posts/{postId}/comments")
+@RequiredArgsConstructor
+@Slf4j
+@Tag(name = "Comments", description = "Operations related to comments")
+public class CommentController {
+
+ private final CommentService commentService;
+ private final PostService postService;
+
+ @GetMapping
+ @Operation(summary = "List comments for a post", description = "Retrieve all comments on a specific post.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Successfully retrieved comments"),
+ @ApiResponse(responseCode = "404", description = "Post not found"),
+ @ApiResponse(responseCode = "500", description = "Internal server error")
+ })
+ public ResponseEntity> getCommentsByPostId(@PathVariable String postId) {
+ try {
+ // Check if post exists
+ if (!postService.postExists(postId)) {
+ throw new RuntimeException("NOT_FOUND: Post not found");
+ }
+
+ List comments = commentService.getCommentsByPostId(postId);
+ return ResponseEntity.ok(comments);
+ } catch (RuntimeException e) {
+ if (e.getMessage().startsWith("NOT_FOUND")) {
+ throw e;
+ }
+ log.error("Error retrieving comments for post ID: " + postId, e);
+ throw new RuntimeException("INTERNAL_SERVER_ERROR: " + e.getMessage());
+ }
+ }
+
+ @PostMapping
+ @Operation(summary = "Create a comment", description = "Add a comment to a post to share your thoughts.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "201", description = "Comment created successfully"),
+ @ApiResponse(responseCode = "400", description = "Invalid request data"),
+ @ApiResponse(responseCode = "404", description = "Post not found"),
+ @ApiResponse(responseCode = "500", description = "Internal server error")
+ })
+ public ResponseEntity createComment(@PathVariable String postId, @Valid @RequestBody NewCommentRequest request) {
+ try {
+ return commentService.createComment(postId, request)
+ .map(comment -> ResponseEntity.status(HttpStatus.CREATED).body(comment))
+ .orElseThrow(() -> new RuntimeException("NOT_FOUND: Post not found"));
+ } catch (RuntimeException e) {
+ if (e.getMessage().startsWith("NOT_FOUND")) {
+ throw e;
+ }
+ log.error("Error creating comment for post ID: " + postId, e);
+ throw new RuntimeException("INTERNAL_SERVER_ERROR: " + e.getMessage());
+ }
+ }
+
+ @GetMapping("/{commentId}")
+ @Operation(summary = "Get a specific comment", description = "Retrieve a specific comment by its ID.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Comment found"),
+ @ApiResponse(responseCode = "404", description = "Comment not found"),
+ @ApiResponse(responseCode = "500", description = "Internal server error")
+ })
+ public ResponseEntity getCommentById(@PathVariable String postId, @PathVariable String commentId) {
+ try {
+ return commentService.getCommentById(postId, commentId)
+ .map(comment -> ResponseEntity.ok(comment))
+ .orElseThrow(() -> new RuntimeException("NOT_FOUND: Comment not found"));
+ } catch (RuntimeException e) {
+ if (e.getMessage().startsWith("NOT_FOUND")) {
+ throw e;
+ }
+ log.error("Error retrieving comment with ID: " + commentId + " for post ID: " + postId, e);
+ throw new RuntimeException("INTERNAL_SERVER_ERROR: " + e.getMessage());
+ }
+ }
+
+ @PatchMapping("/{commentId}")
+ @Operation(summary = "Update a comment", description = "Update an existing comment to correct or revise it.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200", description = "Comment updated successfully"),
+ @ApiResponse(responseCode = "400", description = "Invalid request data"),
+ @ApiResponse(responseCode = "404", description = "Comment not found or no permission"),
+ @ApiResponse(responseCode = "500", description = "Internal server error")
+ })
+ public ResponseEntity updateComment(@PathVariable String postId, @PathVariable String commentId, @Valid @RequestBody UpdateCommentRequest request) {
+ try {
+ return commentService.updateComment(postId, commentId, request)
+ .map(comment -> ResponseEntity.ok(comment))
+ .orElseThrow(() -> new RuntimeException("NOT_FOUND: Comment not found or you don't have permission to update it"));
+ } catch (RuntimeException e) {
+ if (e.getMessage().startsWith("NOT_FOUND")) {
+ throw e;
+ }
+ log.error("Error updating comment with ID: " + commentId + " for post ID: " + postId, e);
+ throw new RuntimeException("INTERNAL_SERVER_ERROR: " + e.getMessage());
+ }
+ }
+
+ @DeleteMapping("/{commentId}")
+ @Operation(summary = "Delete a comment", description = "Delete a comment if you no longer want it shared.")
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "204", description = "Comment deleted successfully"),
+ @ApiResponse(responseCode = "404", description = "Comment not found"),
+ @ApiResponse(responseCode = "500", description = "Internal server error")
+ })
+ public ResponseEntity deleteComment(@PathVariable String postId, @PathVariable String commentId) {
+ try {
+ boolean deleted = commentService.deleteComment(postId, commentId);
+ if (deleted) {
+ return ResponseEntity.noContent().build();
+ } else {
+ throw new RuntimeException("NOT_FOUND: Comment not found");
+ }
+ } catch (RuntimeException e) {
+ if (e.getMessage().startsWith("NOT_FOUND")) {
+ throw e;
+ }
+ log.error("Error deleting comment with ID: " + commentId + " for post ID: " + postId, e);
+ throw new RuntimeException("INTERNAL_SERVER_ERROR: " + e.getMessage());
+ }
+ }
+}
diff --git a/complete/java/socialapp/src/main/java/com/contoso/socialapp/controller/HealthController.java b/complete/java/socialapp/src/main/java/com/contoso/socialapp/controller/HealthController.java
new file mode 100644
index 0000000..9011266
--- /dev/null
+++ b/complete/java/socialapp/src/main/java/com/contoso/socialapp/controller/HealthController.java
@@ -0,0 +1,36 @@
+package com.contoso.socialapp.controller;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * Health check controller for the Social App.
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api")
+public class HealthController {
+
+ /**
+ * Health check endpoint.
+ */
+ @GetMapping("/health")
+ public ResponseEntity