Compare commits

...

11 commits

Author SHA1 Message Date
c5f77c9ad8 Add database seeder for demo data
Created ForumSeeder to generate sample communities, posts, comments, and votes.
Useful for testing and demonstrating the forum functionality.
2025-12-24 14:00:00 +08:00
8a17eb65c0 Add comprehensive documentation
Created detailed README with feature list, installation guide, and architecture overview.
2025-12-23 17:30:00 +08:00
7a9fd58601 Implement user profile pages
Created user profile view showing posts, comments, and karma.
Added user activity history with pagination.
2025-12-21 12:00:00 +08:00
2e3a97c09d Add post creation and display features
Implemented post creation form with multiple content types.
Added detailed post view with nested comment threading.
2025-12-19 15:45:00 +08:00
d0f10e0590 Build community and home page views
Added community listing, detail pages, and creation forms.
Implemented home page with popular posts feed.
2025-12-17 10:15:00 +08:00
4efa2b09e3 Create layout and authentication views
Added main layout with navigation and authentication pages.
Implemented responsive design using Tailwind CSS.
2025-12-14 13:30:00 +08:00
b081d23f88 Configure application routes
Set up all routes for authentication, communities, posts, comments, and voting.
Organized routes with proper middleware for authenticated users.
2025-12-12 11:00:00 +08:00
a158e64985 Implement comment, voting, and user profile features
Added controllers for comments, voting system, user profiles, and home page.
Includes nested comment support and karma calculation.
2025-12-09 16:20:00 +08:00
21ace0f38e Add authentication and community controllers
Implemented user registration/login system and community management features.
Added controllers for handling posts and community subscriptions.
2025-12-04 09:45:00 +08:00
b453c1020a Implement Eloquent models and relationships
Added Community, Post, Comment, and Vote models with proper relationships.
Updated User model to include karma field and forum-related relationships.
2025-11-29 14:15:00 +08:00
34f73d3207 Add database migrations for forum functionality
Created migrations for communities, posts, comments, votes, and user karma tracking.
2025-11-24 10:30:00 +08:00
32 changed files with 1624 additions and 12 deletions

49
README_FORUM.md Normal file
View file

@ -0,0 +1,49 @@
# Reddit-Like Community Forum
A full-featured community forum built with Laravel, inspired by Reddit.
## Features
- User authentication (registration, login, logout)
- Community creation and management
- Post creation with multiple types (text, link, image)
- Nested comment threading
- Upvote/downvote system for posts and comments
- User karma tracking
- Community subscriptions
- User profiles with activity history
## Database Schema
- **Users**: User accounts with karma scores
- **Communities**: Subreddit-like communities
- **Posts**: User-submitted content
- **Comments**: Nested comments on posts
- **Votes**: Upvotes and downvotes
- **Community_User**: User subscriptions to communities
## Routes
- `/` - Home page with popular posts
- `/register` - User registration
- `/login` - User login
- `/communities` - Browse all communities
- `/r/{community}` - View community posts
- `/posts/{post}` - View post details and comments
- `/u/{user}` - View user profile
## Tech Stack
- Laravel 12
- Tailwind CSS
- Blade Templates
- MySQL/SQLite
## Installation
1. Clone the repository
2. Run `composer install`
3. Copy `.env.example` to `.env`
4. Generate application key: `php artisan key:generate`
5. Run migrations: `php artisan migrate`
6. Start the server: `php artisan serve`

View file

@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
public function showRegister()
{
return view('auth.register');
}
public function register(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'karma' => 0,
]);
Auth::login($user);
return redirect('/')->with('success', 'Registration successful!');
}
public function showLogin()
{
return view('auth.login');
}
public function login(Request $request)
{
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
if (Auth::attempt($credentials, $request->boolean('remember'))) {
$request->session()->regenerate();
return redirect()->intended('/');
}
return back()->withErrors([
'email' => 'The provided credentials do not match our records.',
])->onlyInput('email');
}
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use App\Models\Comment;
use App\Models\Post;
use Illuminate\Http\Request;
class CommentController extends Controller
{
public function store(Request $request, Post $post)
{
$validated = $request->validate([
'content' => 'required|string',
'parent_id' => 'nullable|exists:comments,id',
]);
$comment = Comment::create([
'post_id' => $post->id,
'user_id' => auth()->id(),
'parent_id' => $validated['parent_id'] ?? null,
'content' => $validated['content'],
]);
return back()->with('success', 'Comment posted successfully!');
}
public function destroy(Comment $comment)
{
if ($comment->user_id !== auth()->id()) {
abort(403);
}
$comment->delete();
return back()->with('success', 'Comment deleted successfully!');
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers;
use App\Models\Community;
use Illuminate\Http\Request;
class CommunityController extends Controller
{
public function index()
{
$communities = Community::withCount(['posts', 'subscribers'])
->orderBy('created_at', 'desc')
->paginate(20);
return view('communities.index', compact('communities'));
}
public function show(Community $community)
{
$posts = $community->posts()
->with(['user', 'comments'])
->orderBy('votes', 'desc')
->orderBy('created_at', 'desc')
->paginate(20);
return view('communities.show', compact('community', 'posts'));
}
public function create()
{
return view('communities.create');
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:communities',
'description' => 'nullable|string',
]);
$community = Community::create([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'created_by' => auth()->id(),
]);
return redirect()->route('communities.show', $community)
->with('success', 'Community created successfully!');
}
public function subscribe(Community $community)
{
auth()->user()->subscribedCommunities()->attach($community->id);
return back()->with('success', 'Subscribed to ' . $community->name);
}
public function unsubscribe(Community $community)
{
auth()->user()->subscribedCommunities()->detach($community->id);
return back()->with('success', 'Unsubscribed from ' . $community->name);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class HomeController extends Controller
{
public function index()
{
$posts = Post::with(['user', 'community', 'comments'])
->orderBy('votes', 'desc')
->orderBy('created_at', 'desc')
->paginate(20);
return view('home', compact('posts'));
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers;
use App\Models\Community;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function create()
{
$communities = Community::orderBy('name')->get();
return view('posts.create', compact('communities'));
}
public function store(Request $request)
{
$validated = $request->validate([
'community_id' => 'required|exists:communities,id',
'title' => 'required|string|max:255',
'content' => 'nullable|string',
'url' => 'nullable|url',
'type' => 'required|in:text,link,image',
]);
$post = Post::create([
'community_id' => $validated['community_id'],
'user_id' => auth()->id(),
'title' => $validated['title'],
'content' => $validated['content'] ?? null,
'url' => $validated['url'] ?? null,
'type' => $validated['type'],
]);
return redirect()->route('posts.show', $post)
->with('success', 'Post created successfully!');
}
public function show(Post $post)
{
$post->load(['user', 'community', 'comments.user', 'comments.replies.user']);
return view('posts.show', compact('post'));
}
public function destroy(Post $post)
{
if ($post->user_id !== auth()->id()) {
abort(403);
}
$post->delete();
return redirect('/')->with('success', 'Post deleted successfully!');
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
class UserController extends Controller
{
public function show(User $user)
{
$posts = $user->posts()
->with(['community', 'comments'])
->orderBy('created_at', 'desc')
->paginate(20);
$comments = $user->comments()
->with(['post', 'post.community'])
->orderBy('created_at', 'desc')
->paginate(20);
return view('users.show', compact('user', 'posts', 'comments'));
}
}

View file

@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers;
use App\Models\Comment;
use App\Models\Post;
use App\Models\Vote;
use Illuminate\Http\Request;
class VoteController extends Controller
{
public function votePost(Request $request, Post $post)
{
$validated = $request->validate([
'vote' => 'required|in:1,-1',
]);
$existingVote = Vote::where([
'user_id' => auth()->id(),
'voteable_id' => $post->id,
'voteable_type' => Post::class,
])->first();
if ($existingVote) {
if ($existingVote->vote == $validated['vote']) {
$existingVote->delete();
$this->updateVoteCount($post);
return back();
}
$existingVote->update(['vote' => $validated['vote']]);
} else {
Vote::create([
'user_id' => auth()->id(),
'voteable_id' => $post->id,
'voteable_type' => Post::class,
'vote' => $validated['vote'],
]);
}
$this->updateVoteCount($post);
$this->updateUserKarma($post->user);
return back();
}
public function voteComment(Request $request, Comment $comment)
{
$validated = $request->validate([
'vote' => 'required|in:1,-1',
]);
$existingVote = Vote::where([
'user_id' => auth()->id(),
'voteable_id' => $comment->id,
'voteable_type' => Comment::class,
])->first();
if ($existingVote) {
if ($existingVote->vote == $validated['vote']) {
$existingVote->delete();
$this->updateVoteCount($comment);
return back();
}
$existingVote->update(['vote' => $validated['vote']]);
} else {
Vote::create([
'user_id' => auth()->id(),
'voteable_id' => $comment->id,
'voteable_type' => Comment::class,
'vote' => $validated['vote'],
]);
}
$this->updateVoteCount($comment);
$this->updateUserKarma($comment->user);
return back();
}
private function updateVoteCount($model)
{
$model->votes = $model->votes()->sum('vote');
$model->save();
}
private function updateUserKarma($user)
{
$postKarma = $user->posts()->sum('votes');
$commentKarma = $user->comments()->sum('votes');
$user->karma = $postKarma + $commentKarma;
$user->save();
}
}

56
app/Models/Comment.php Normal file
View file

@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Comment extends Model
{
use HasFactory;
protected $fillable = [
'post_id',
'user_id',
'parent_id',
'content',
'votes',
];
protected function casts(): array
{
return [
'votes' => 'integer',
];
}
public function post()
{
return $this->belongsTo(Post::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function parent()
{
return $this->belongsTo(Comment::class, 'parent_id');
}
public function replies()
{
return $this->hasMany(Comment::class, 'parent_id');
}
public function votes()
{
return $this->morphMany(Vote::class, 'voteable');
}
public function userVote($userId)
{
return $this->votes()->where('user_id', $userId)->first();
}
}

50
app/Models/Community.php Normal file
View file

@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class Community extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
'created_by',
];
protected static function boot()
{
parent::boot();
static::creating(function ($community) {
if (empty($community->slug)) {
$community->slug = Str::slug($community->name);
}
});
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function subscribers()
{
return $this->belongsToMany(User::class)->withTimestamps();
}
public function posts()
{
return $this->hasMany(Post::class);
}
public function getRouteKeyName()
{
return 'slug';
}
}

53
app/Models/Post.php Normal file
View file

@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'community_id',
'user_id',
'title',
'content',
'url',
'type',
'votes',
];
protected function casts(): array
{
return [
'votes' => 'integer',
];
}
public function community()
{
return $this->belongsTo(Community::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function comments()
{
return $this->hasMany(Comment::class);
}
public function votes()
{
return $this->morphMany(Vote::class, 'voteable');
}
public function userVote($userId)
{
return $this->votes()->where('user_id', $userId)->first();
}
}

View file

@ -21,23 +21,14 @@ class User extends Authenticatable
'name', 'name',
'email', 'email',
'password', 'password',
'karma',
]; ];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [ protected $hidden = [
'password', 'password',
'remember_token', 'remember_token',
]; ];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array protected function casts(): array
{ {
return [ return [
@ -45,4 +36,29 @@ protected function casts(): array
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
public function communities()
{
return $this->hasMany(Community::class, 'created_by');
}
public function subscribedCommunities()
{
return $this->belongsToMany(Community::class)->withTimestamps();
}
public function posts()
{
return $this->hasMany(Post::class);
}
public function comments()
{
return $this->hasMany(Comment::class);
}
public function votes()
{
return $this->hasMany(Vote::class);
}
} }

35
app/Models/Vote.php Normal file
View file

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Vote extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'voteable_id',
'voteable_type',
'vote',
];
protected function casts(): array
{
return [
'vote' => 'integer',
];
}
public function user()
{
return $this->belongsTo(User::class);
}
public function voteable()
{
return $this->morphTo();
}
}

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->integer('karma')->default(0)->after('email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('karma');
});
}
};

View file

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('communities', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->foreignId('created_by')->constrained('users')->onDelete('cascade');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('communities');
}
};

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('community_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->text('content')->nullable();
$table->string('url')->nullable();
$table->enum('type', ['text', 'link', 'image'])->default('text');
$table->integer('votes')->default(0);
$table->timestamps();
$table->index(['community_id', 'created_at']);
$table->index(['user_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('parent_id')->nullable()->constrained('comments')->onDelete('cascade');
$table->text('content');
$table->integer('votes')->default(0);
$table->timestamps();
$table->index(['post_id', 'created_at']);
$table->index(['parent_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('comments');
}
};

View file

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('votes', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->morphs('voteable');
$table->tinyInteger('vote');
$table->timestamps();
$table->unique(['user_id', 'voteable_id', 'voteable_type']);
});
}
public function down(): void
{
Schema::dropIfExists('votes');
}
};

View file

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('community_user', function (Blueprint $table) {
$table->id();
$table->foreignId('community_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->timestamps();
$table->unique(['community_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('community_user');
}
};

View file

@ -0,0 +1,93 @@
<?php
namespace Database\Seeders;
use App\Models\Comment;
use App\Models\Community;
use App\Models\Post;
use App\Models\User;
use App\Models\Vote;
use Illuminate\Database\Seeder;
class ForumSeeder extends Seeder
{
public function run(): void
{
$users = User::factory(10)->create();
$communities = [];
$communityNames = ['Programming', 'Gaming', 'Music', 'Movies', 'Technology'];
foreach ($communityNames as $name) {
$communities[] = Community::create([
'name' => $name,
'description' => "A community for {$name} enthusiasts",
'created_by' => $users->random()->id,
]);
}
foreach ($users as $user) {
$user->subscribedCommunities()->attach(
collect($communities)->random(rand(1, 3))->pluck('id')
);
}
foreach ($communities as $community) {
$posts = [];
for ($i = 0; $i < rand(5, 15); $i++) {
$posts[] = Post::create([
'community_id' => $community->id,
'user_id' => $users->random()->id,
'title' => fake()->sentence(),
'content' => rand(0, 1) ? fake()->paragraphs(rand(1, 3), true) : null,
'type' => 'text',
]);
}
foreach ($posts as $post) {
for ($i = 0; $i < rand(0, 5); $i++) {
$comment = Comment::create([
'post_id' => $post->id,
'user_id' => $users->random()->id,
'content' => fake()->paragraph(),
]);
if (rand(0, 1)) {
Comment::create([
'post_id' => $post->id,
'user_id' => $users->random()->id,
'parent_id' => $comment->id,
'content' => fake()->paragraph(),
]);
}
}
foreach ($users->random(rand(1, 8)) as $voter) {
Vote::create([
'user_id' => $voter->id,
'voteable_id' => $post->id,
'voteable_type' => Post::class,
'vote' => rand(0, 1) ? 1 : -1,
]);
}
}
}
foreach (Post::all() as $post) {
$post->votes = $post->votes()->sum('vote');
$post->save();
}
foreach (Comment::all() as $comment) {
$comment->votes = $comment->votes()->sum('vote');
$comment->save();
}
foreach (User::all() as $user) {
$postKarma = $user->posts()->sum('votes');
$commentKarma = $user->comments()->sum('votes');
$user->karma = $postKarma + $commentKarma;
$user->save();
}
}
}

View file

@ -0,0 +1,46 @@
@extends('layout')
@section('title', 'Login')
@section('content')
<div class="max-w-md mx-auto bg-white rounded-lg shadow p-8">
<h1 class="text-2xl font-bold mb-6">Login</h1>
<form action="{{ route('login') }}" method="POST">
@csrf
<div class="mb-4">
<label for="email" class="block text-gray-700 font-bold mb-2">Email</label>
<input type="email" id="email" name="email" value="{{ old('email') }}" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500">
@error('email')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="password" class="block text-gray-700 font-bold mb-2">Password</label>
<input type="password" id="password" name="password" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500">
@error('password')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label class="flex items-center">
<input type="checkbox" name="remember" class="mr-2">
<span class="text-gray-700">Remember me</span>
</label>
</div>
<button type="submit" class="w-full bg-orange-600 text-white py-2 rounded hover:bg-orange-700 font-bold">
Login
</button>
</form>
<p class="mt-4 text-center text-gray-600">
Don't have an account? <a href="{{ route('register') }}" class="text-orange-600 hover:underline">Sign up</a>
</p>
</div>
@endsection

View file

@ -0,0 +1,54 @@
@extends('layout')
@section('title', 'Register')
@section('content')
<div class="max-w-md mx-auto bg-white rounded-lg shadow p-8">
<h1 class="text-2xl font-bold mb-6">Create Account</h1>
<form action="{{ route('register') }}" method="POST">
@csrf
<div class="mb-4">
<label for="name" class="block text-gray-700 font-bold mb-2">Username</label>
<input type="text" id="name" name="name" value="{{ old('name') }}" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500">
@error('name')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="email" class="block text-gray-700 font-bold mb-2">Email</label>
<input type="email" id="email" name="email" value="{{ old('email') }}" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500">
@error('email')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="password" class="block text-gray-700 font-bold mb-2">Password</label>
<input type="password" id="password" name="password" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500">
@error('password')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="password_confirmation" class="block text-gray-700 font-bold mb-2">Confirm Password</label>
<input type="password" id="password_confirmation" name="password_confirmation" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500">
</div>
<button type="submit" class="w-full bg-orange-600 text-white py-2 rounded hover:bg-orange-700 font-bold">
Sign Up
</button>
</form>
<p class="mt-4 text-center text-gray-600">
Already have an account? <a href="{{ route('login') }}" class="text-orange-600 hover:underline">Login</a>
</p>
</div>
@endsection

View file

@ -0,0 +1,39 @@
@extends('layout')
@section('title', 'Create Community')
@section('content')
<div class="max-w-2xl mx-auto bg-white rounded-lg shadow p-8">
<h1 class="text-2xl font-bold mb-6">Create a Community</h1>
<form action="{{ route('communities.store') }}" method="POST">
@csrf
<div class="mb-4">
<label for="name" class="block text-gray-700 font-bold mb-2">Name</label>
<div class="flex items-center">
<span class="text-gray-700 mr-2">r/</span>
<input type="text" id="name" name="name" value="{{ old('name') }}" required
class="flex-1 px-3 py-2 border rounded focus:outline-none focus:border-orange-500">
</div>
<p class="text-sm text-gray-600 mt-1">Community names cannot be changed</p>
@error('name')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="description" class="block text-gray-700 font-bold mb-2">Description</label>
<textarea id="description" name="description" rows="4"
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500">{{ old('description') }}</textarea>
@error('description')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="bg-orange-600 text-white px-6 py-2 rounded hover:bg-orange-700 font-bold">
Create Community
</button>
</form>
</div>
@endsection

View file

@ -0,0 +1,35 @@
@extends('layout')
@section('title', 'Communities')
@section('content')
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Communities</h1>
@auth
<a href="{{ route('communities.create') }}" class="bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700">Create Community</a>
@endauth
</div>
@forelse($communities as $community)
<div class="bg-white rounded-lg shadow mb-4 p-6">
<h2 class="text-2xl font-bold mb-2">
<a href="{{ route('communities.show', $community) }}" class="hover:text-orange-600">r/{{ $community->name }}</a>
</h2>
@if($community->description)
<p class="text-gray-700 mb-3">{{ $community->description }}</p>
@endif
<div class="text-sm text-gray-600">
{{ $community->posts_count }} posts {{ $community->subscribers_count }} subscribers
Created {{ $community->created_at->diffForHumans() }}
</div>
</div>
@empty
<p class="text-gray-600">No communities yet. Be the first to create one!</p>
@endforelse
<div class="mt-4">
{{ $communities->links() }}
</div>
</div>
@endsection

View file

@ -0,0 +1,94 @@
@extends('layout')
@section('title', 'r/' . $community->name)
@section('content')
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<h1 class="text-3xl font-bold mb-6">r/{{ $community->name }}</h1>
@forelse($posts as $post)
<div class="bg-white rounded-lg shadow mb-4 p-4 flex">
<div class="flex flex-col items-center mr-4">
@auth
<form action="{{ route('posts.vote', $post) }}" method="POST">
@csrf
<input type="hidden" name="vote" value="1">
<button type="submit" class="text-gray-400 hover:text-orange-600"></button>
</form>
@else
<span class="text-gray-400"></span>
@endauth
<span class="font-bold {{ $post->votes > 0 ? 'text-orange-600' : ($post->votes < 0 ? 'text-blue-600' : 'text-gray-600') }}">
{{ $post->votes }}
</span>
@auth
<form action="{{ route('posts.vote', $post) }}" method="POST">
@csrf
<input type="hidden" name="vote" value="-1">
<button type="submit" class="text-gray-400 hover:text-blue-600"></button>
</form>
@else
<span class="text-gray-400"></span>
@endauth
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold mb-2">
<a href="{{ route('posts.show', $post) }}" class="hover:text-orange-600">{{ $post->title }}</a>
</h2>
<div class="text-sm text-gray-600 mb-2">
Posted by <a href="{{ route('users.show', $post->user) }}" class="hover:underline">u/{{ $post->user->name }}</a>
{{ $post->created_at->diffForHumans() }}
</div>
@if($post->content)
<p class="text-gray-700 mb-2">{{ Str::limit($post->content, 200) }}</p>
@endif
@if($post->url)
<a href="{{ $post->url }}" target="_blank" class="text-blue-600 hover:underline">{{ $post->url }}</a>
@endif
<div class="mt-2">
<a href="{{ route('posts.show', $post) }}" class="text-gray-600 hover:underline">
{{ $post->comments->count() }} comments
</a>
</div>
</div>
</div>
@empty
<p class="text-gray-600">No posts yet. Be the first to post!</p>
@endforelse
<div class="mt-4">
{{ $posts->links() }}
</div>
</div>
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow p-4">
<h2 class="text-xl font-bold mb-4">About r/{{ $community->name }}</h2>
@if($community->description)
<p class="text-gray-700 mb-4">{{ $community->description }}</p>
@endif
<div class="text-sm text-gray-600 mb-4">
Created by <a href="{{ route('users.show', $community->creator) }}" class="hover:underline">u/{{ $community->creator->name }}</a>
{{ $community->created_at->diffForHumans() }}
</div>
@auth
@if(auth()->user()->subscribedCommunities->contains($community))
<form action="{{ route('communities.unsubscribe', $community) }}" method="POST">
@csrf
<button type="submit" class="w-full bg-gray-200 text-gray-800 px-4 py-2 rounded hover:bg-gray-300">Unsubscribe</button>
</form>
@else
<form action="{{ route('communities.subscribe', $community) }}" method="POST">
@csrf
<button type="submit" class="w-full bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700">Subscribe</button>
</form>
@endif
@endauth
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,81 @@
@extends('layout')
@section('title', 'Home')
@section('content')
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<h1 class="text-3xl font-bold mb-6">Popular Posts</h1>
@forelse($posts as $post)
<div class="bg-white rounded-lg shadow mb-4 p-4 flex">
<div class="flex flex-col items-center mr-4">
@auth
<form action="{{ route('posts.vote', $post) }}" method="POST">
@csrf
<input type="hidden" name="vote" value="1">
<button type="submit" class="text-gray-400 hover:text-orange-600"></button>
</form>
@else
<span class="text-gray-400"></span>
@endauth
<span class="font-bold {{ $post->votes > 0 ? 'text-orange-600' : ($post->votes < 0 ? 'text-blue-600' : 'text-gray-600') }}">
{{ $post->votes }}
</span>
@auth
<form action="{{ route('posts.vote', $post) }}" method="POST">
@csrf
<input type="hidden" name="vote" value="-1">
<button type="submit" class="text-gray-400 hover:text-blue-600"></button>
</form>
@else
<span class="text-gray-400"></span>
@endauth
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold mb-2">
<a href="{{ route('posts.show', $post) }}" class="hover:text-orange-600">{{ $post->title }}</a>
</h2>
<div class="text-sm text-gray-600 mb-2">
<a href="{{ route('communities.show', $post->community) }}" class="font-bold hover:underline">r/{{ $post->community->name }}</a>
Posted by <a href="{{ route('users.show', $post->user) }}" class="hover:underline">u/{{ $post->user->name }}</a>
{{ $post->created_at->diffForHumans() }}
</div>
@if($post->content)
<p class="text-gray-700 mb-2">{{ Str::limit($post->content, 200) }}</p>
@endif
@if($post->url)
<a href="{{ $post->url }}" target="_blank" class="text-blue-600 hover:underline">{{ $post->url }}</a>
@endif
<div class="mt-2">
<a href="{{ route('posts.show', $post) }}" class="text-gray-600 hover:underline">
{{ $post->comments->count() }} comments
</a>
</div>
</div>
</div>
@empty
<p class="text-gray-600">No posts yet. Be the first to create one!</p>
@endforelse
<div class="mt-4">
{{ $posts->links() }}
</div>
</div>
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow p-4">
<h2 class="text-xl font-bold mb-4">About</h2>
<p class="text-gray-700 mb-4">Welcome to our Reddit-like community forum! Share your thoughts, engage in discussions, and connect with others.</p>
@auth
<a href="{{ route('posts.create') }}" class="block w-full text-center bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700">Create Post</a>
@else
<a href="{{ route('register') }}" class="block w-full text-center bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700">Sign Up</a>
@endauth
</div>
</div>
</div>
@endsection

View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@yield('title', 'Reddit Clone')</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100">
<nav class="bg-white shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center space-x-8">
<a href="{{ route('home') }}" class="text-2xl font-bold text-orange-600">Reddit Clone</a>
<a href="{{ route('home') }}" class="text-gray-700 hover:text-gray-900">Home</a>
<a href="{{ route('communities.index') }}" class="text-gray-700 hover:text-gray-900">Communities</a>
</div>
<div class="flex items-center space-x-4">
@auth
<a href="{{ route('posts.create') }}" class="bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700">Create Post</a>
<a href="{{ route('communities.create') }}" class="text-gray-700 hover:text-gray-900">Create Community</a>
<a href="{{ route('users.show', auth()->user()) }}" class="text-gray-700 hover:text-gray-900">{{ auth()->user()->name }} ({{ auth()->user()->karma }})</a>
<form action="{{ route('logout') }}" method="POST" class="inline">
@csrf
<button type="submit" class="text-gray-700 hover:text-gray-900">Logout</button>
</form>
@else
<a href="{{ route('login') }}" class="text-gray-700 hover:text-gray-900">Login</a>
<a href="{{ route('register') }}" class="bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700">Sign Up</a>
@endauth
</div>
</div>
</div>
</nav>
@if(session('success'))
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4">
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
{{ session('success') }}
</div>
</div>
@endif
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@yield('content')
</main>
</body>
</html>

View file

@ -0,0 +1,80 @@
<div class="mb-4" style="margin-left: {{ $depth * 20 }}px;">
<div class="border-l-2 border-gray-200 pl-4">
<div class="flex items-start mb-2">
<div class="flex flex-col items-center mr-2">
@auth
<form action="{{ route('comments.vote', $comment) }}" method="POST">
@csrf
<input type="hidden" name="vote" value="1">
<button type="submit" class="text-gray-400 hover:text-orange-600 text-xs"></button>
</form>
@else
<span class="text-gray-400 text-xs"></span>
@endauth
<span class="text-xs font-bold {{ $comment->votes > 0 ? 'text-orange-600' : ($comment->votes < 0 ? 'text-blue-600' : 'text-gray-600') }}">
{{ $comment->votes }}
</span>
@auth
<form action="{{ route('comments.vote', $comment) }}" method="POST">
@csrf
<input type="hidden" name="vote" value="-1">
<button type="submit" class="text-gray-400 hover:text-blue-600 text-xs"></button>
</form>
@else
<span class="text-gray-400 text-xs"></span>
@endauth
</div>
<div class="flex-1">
<div class="text-sm text-gray-600 mb-1">
<a href="{{ route('users.show', $comment->user) }}" class="font-bold hover:underline">u/{{ $comment->user->name }}</a>
{{ $comment->created_at->diffForHumans() }}
</div>
<p class="text-gray-800 whitespace-pre-wrap">{{ $comment->content }}</p>
@auth
<div class="mt-2 text-sm">
<button onclick="toggleReplyForm({{ $comment->id }})" class="text-gray-600 hover:underline">Reply</button>
@if($comment->user_id === auth()->id())
<form action="{{ route('comments.destroy', $comment) }}" method="POST" class="inline ml-2">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline" onclick="return confirm('Are you sure?')">Delete</button>
</form>
@endif
</div>
<div id="reply-form-{{ $comment->id }}" class="hidden mt-2">
<form action="{{ route('comments.store', $comment->post) }}" method="POST">
@csrf
<input type="hidden" name="parent_id" value="{{ $comment->id }}">
<textarea name="content" rows="3" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500"></textarea>
<button type="submit" class="mt-2 bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700 text-sm">
Reply
</button>
<button type="button" onclick="toggleReplyForm({{ $comment->id }})" class="mt-2 text-gray-600 hover:underline text-sm ml-2">
Cancel
</button>
</form>
</div>
@endauth
</div>
</div>
@foreach($comment->replies as $reply)
@include('partials.comment', ['comment' => $reply, 'depth' => $depth + 1])
@endforeach
</div>
</div>
@if($depth === 0)
<script>
function toggleReplyForm(commentId) {
const form = document.getElementById('reply-form-' + commentId);
form.classList.toggle('hidden');
}
</script>
@endif

View file

@ -0,0 +1,88 @@
@extends('layout')
@section('title', 'Create Post')
@section('content')
<div class="max-w-2xl mx-auto bg-white rounded-lg shadow p-8">
<h1 class="text-2xl font-bold mb-6">Create a Post</h1>
<form action="{{ route('posts.store') }}" method="POST">
@csrf
<div class="mb-4">
<label for="community_id" class="block text-gray-700 font-bold mb-2">Community</label>
<select id="community_id" name="community_id" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500">
<option value="">Select a community</option>
@foreach($communities as $community)
<option value="{{ $community->id }}" {{ old('community_id') == $community->id ? 'selected' : '' }}>
r/{{ $community->name }}
</option>
@endforeach
</select>
@error('community_id')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="type" class="block text-gray-700 font-bold mb-2">Post Type</label>
<select id="type" name="type" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500">
<option value="text" {{ old('type') == 'text' ? 'selected' : '' }}>Text</option>
<option value="link" {{ old('type') == 'link' ? 'selected' : '' }}>Link</option>
<option value="image" {{ old('type') == 'image' ? 'selected' : '' }}>Image</option>
</select>
@error('type')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="title" class="block text-gray-700 font-bold mb-2">Title</label>
<input type="text" id="title" name="title" value="{{ old('title') }}" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500">
@error('title')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4" id="content-field">
<label for="content" class="block text-gray-700 font-bold mb-2">Content</label>
<textarea id="content" name="content" rows="6"
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500">{{ old('content') }}</textarea>
@error('content')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-6 hidden" id="url-field">
<label for="url" class="block text-gray-700 font-bold mb-2">URL</label>
<input type="url" id="url" name="url" value="{{ old('url') }}"
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500">
@error('url')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="bg-orange-600 text-white px-6 py-2 rounded hover:bg-orange-700 font-bold">
Create Post
</button>
</form>
</div>
<script>
document.getElementById('type').addEventListener('change', function() {
const contentField = document.getElementById('content-field');
const urlField = document.getElementById('url-field');
if (this.value === 'text') {
contentField.classList.remove('hidden');
urlField.classList.add('hidden');
} else {
contentField.classList.add('hidden');
urlField.classList.remove('hidden');
}
});
</script>
@endsection

View file

@ -0,0 +1,89 @@
@extends('layout')
@section('title', $post->title)
@section('content')
<div class="max-w-4xl mx-auto">
<div class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex">
<div class="flex flex-col items-center mr-4">
@auth
<form action="{{ route('posts.vote', $post) }}" method="POST">
@csrf
<input type="hidden" name="vote" value="1">
<button type="submit" class="text-gray-400 hover:text-orange-600"></button>
</form>
@else
<span class="text-gray-400"></span>
@endauth
<span class="font-bold text-lg {{ $post->votes > 0 ? 'text-orange-600' : ($post->votes < 0 ? 'text-blue-600' : 'text-gray-600') }}">
{{ $post->votes }}
</span>
@auth
<form action="{{ route('posts.vote', $post) }}" method="POST">
@csrf
<input type="hidden" name="vote" value="-1">
<button type="submit" class="text-gray-400 hover:text-blue-600"></button>
</form>
@else
<span class="text-gray-400"></span>
@endauth
</div>
<div class="flex-1">
<div class="text-sm text-gray-600 mb-2">
<a href="{{ route('communities.show', $post->community) }}" class="font-bold hover:underline">r/{{ $post->community->name }}</a>
Posted by <a href="{{ route('users.show', $post->user) }}" class="hover:underline">u/{{ $post->user->name }}</a>
{{ $post->created_at->diffForHumans() }}
</div>
<h1 class="text-2xl font-bold mb-4">{{ $post->title }}</h1>
@if($post->content)
<p class="text-gray-700 mb-4 whitespace-pre-wrap">{{ $post->content }}</p>
@endif
@if($post->url)
<a href="{{ $post->url }}" target="_blank" class="text-blue-600 hover:underline">{{ $post->url }}</a>
@endif
@auth
@if($post->user_id === auth()->id())
<form action="{{ route('posts.destroy', $post) }}" method="POST" class="mt-4">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline" onclick="return confirm('Are you sure?')">Delete Post</button>
</form>
@endif
@endauth
</div>
</div>
</div>
@auth
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-xl font-bold mb-4">Add a Comment</h2>
<form action="{{ route('comments.store', $post) }}" method="POST">
@csrf
<textarea name="content" rows="4" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:border-orange-500"></textarea>
@error('content')
<p class="text-red-500 text-sm mt-1">{{ $message }}</p>
@enderror
<button type="submit" class="mt-2 bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700">
Comment
</button>
</form>
</div>
@endauth
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-bold mb-4">Comments ({{ $post->comments->count() }})</h2>
@forelse($post->comments->whereNull('parent_id') as $comment)
@include('partials.comment', ['comment' => $comment, 'depth' => 0])
@empty
<p class="text-gray-600">No comments yet. Be the first to comment!</p>
@endforelse
</div>
</div>
@endsection

View file

@ -0,0 +1,57 @@
@extends('layout')
@section('title', 'u/' . $user->name)
@section('content')
<div class="max-w-4xl mx-auto">
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h1 class="text-3xl font-bold mb-2">u/{{ $user->name }}</h1>
<div class="text-gray-600">
<p>Karma: {{ $user->karma }}</p>
<p>Joined {{ $user->created_at->diffForHumans() }}</p>
</div>
</div>
<div class="mb-6">
<h2 class="text-2xl font-bold mb-4">Posts</h2>
@forelse($posts as $post)
<div class="bg-white rounded-lg shadow mb-4 p-4">
<h3 class="text-xl font-semibold mb-2">
<a href="{{ route('posts.show', $post) }}" class="hover:text-orange-600">{{ $post->title }}</a>
</h3>
<div class="text-sm text-gray-600">
<a href="{{ route('communities.show', $post->community) }}" class="font-bold hover:underline">r/{{ $post->community->name }}</a>
{{ $post->votes }} votes
{{ $post->comments->count() }} comments
{{ $post->created_at->diffForHumans() }}
</div>
</div>
@empty
<p class="text-gray-600">No posts yet.</p>
@endforelse
<div class="mt-4">
{{ $posts->links() }}
</div>
</div>
<div>
<h2 class="text-2xl font-bold mb-4">Comments</h2>
@forelse($comments as $comment)
<div class="bg-white rounded-lg shadow mb-4 p-4">
<p class="text-gray-700 mb-2">{{ Str::limit($comment->content, 200) }}</p>
<div class="text-sm text-gray-600">
On <a href="{{ route('posts.show', $comment->post) }}" class="hover:underline">{{ $comment->post->title }}</a>
in <a href="{{ route('communities.show', $comment->post->community) }}" class="font-bold hover:underline">r/{{ $comment->post->community->name }}</a>
{{ $comment->votes }} votes
{{ $comment->created_at->diffForHumans() }}
</div>
</div>
@empty
<p class="text-gray-600">No comments yet.</p>
@endforelse
<div class="mt-4">
{{ $comments->links() }}
</div>
</div>
</div>
@endsection

View file

@ -1,7 +1,41 @@
<?php <?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\CommentController;
use App\Http\Controllers\CommunityController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\VoteController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', [HomeController::class, 'index'])->name('home');
return view('welcome');
Route::get('/register', [AuthController::class, 'showRegister'])->name('register');
Route::post('/register', [AuthController::class, 'register']);
Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
Route::get('/communities', [CommunityController::class, 'index'])->name('communities.index');
Route::get('/r/{community}', [CommunityController::class, 'show'])->name('communities.show');
Route::middleware('auth')->group(function () {
Route::get('/communities/create', [CommunityController::class, 'create'])->name('communities.create');
Route::post('/communities', [CommunityController::class, 'store'])->name('communities.store');
Route::post('/r/{community}/subscribe', [CommunityController::class, 'subscribe'])->name('communities.subscribe');
Route::post('/r/{community}/unsubscribe', [CommunityController::class, 'unsubscribe'])->name('communities.unsubscribe');
Route::get('/posts/create', [PostController::class, 'create'])->name('posts.create');
Route::post('/posts', [PostController::class, 'store'])->name('posts.store');
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->name('posts.destroy');
Route::post('/posts/{post}/comments', [CommentController::class, 'store'])->name('comments.store');
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');
Route::post('/posts/{post}/vote', [VoteController::class, 'votePost'])->name('posts.vote');
Route::post('/comments/{comment}/vote', [VoteController::class, 'voteComment'])->name('comments.vote');
}); });
Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');
Route::get('/u/{user}', [UserController::class, 'show'])->name('users.show');