feat: forum rich-text editor, emoji picker, mentions, discover nav, feed, uploads, profile
Forum: - TipTap WYSIWYG editor with full toolbar - @emoji-mart/react emoji picker (consistent with tweets) - @mention autocomplete with user search API - Fix PHP 8.4 parse errors in Blade templates - Fix thread data display (paginator items) - Align forum page widths to max-w-5xl Discover: - Extract shared _nav.blade.php partial - Add missing nav links to for-you page - Add Following link for authenticated users Feed/Posts: - Post model, controllers, policies, migrations - Feed page components (PostComposer, FeedCard, etc) - Post reactions, comments, saves, reports, sharing - Scheduled publishing support - Link preview controller Profile: - Profile page components (ProfileHero, ProfileTabs) - Profile API controller Uploads: - Upload wizard enhancements - Scheduled publish picker - Studio status bar and readiness checklist
This commit is contained in:
@@ -54,12 +54,17 @@ class Artwork extends Model
|
||||
'version_count',
|
||||
'version_updated_at',
|
||||
'requires_reapproval',
|
||||
// Scheduled publishing
|
||||
'publish_at',
|
||||
'artwork_status',
|
||||
'artwork_timezone',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_public' => 'boolean',
|
||||
'is_approved' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'publish_at' => 'datetime',
|
||||
'version_updated_at' => 'datetime',
|
||||
'requires_reapproval' => 'boolean',
|
||||
];
|
||||
|
||||
185
app/Models/Post.php
Normal file
185
app/Models/Post.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $type text | artwork_share | upload | achievement
|
||||
* @property string $visibility public | followers | private
|
||||
* @property string|null $body
|
||||
* @property array|null $meta
|
||||
* @property int $reactions_count
|
||||
* @property int $comments_count
|
||||
* @property string $status draft | scheduled | published
|
||||
* @property bool $is_pinned
|
||||
* @property int|null $pinned_order
|
||||
* @property \Carbon\Carbon|null $publish_at
|
||||
* @property int $impressions_count
|
||||
* @property float $engagement_score
|
||||
* @property int $saves_count
|
||||
*/
|
||||
class Post extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes, Searchable;
|
||||
|
||||
protected $table = 'posts';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'type', 'visibility', 'body', 'meta',
|
||||
'reactions_count', 'comments_count',
|
||||
'is_pinned', 'pinned_order',
|
||||
'publish_at', 'status',
|
||||
'impressions_count', 'engagement_score', 'saves_count',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'meta' => 'array',
|
||||
'reactions_count' => 'integer',
|
||||
'comments_count' => 'integer',
|
||||
'impressions_count' => 'integer',
|
||||
'saves_count' => 'integer',
|
||||
'engagement_score' => 'float',
|
||||
'is_pinned' => 'boolean',
|
||||
'pinned_order' => 'integer',
|
||||
'publish_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public const TYPE_TEXT = 'text';
|
||||
public const TYPE_ARTWORK_SHARE = 'artwork_share';
|
||||
public const TYPE_UPLOAD = 'upload';
|
||||
public const TYPE_ACHIEVEMENT = 'achievement';
|
||||
|
||||
public const VISIBILITY_PUBLIC = 'public';
|
||||
public const VISIBILITY_FOLLOWERS = 'followers';
|
||||
public const VISIBILITY_PRIVATE = 'private';
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_SCHEDULED = 'scheduled';
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Relationships
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function targets(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostTarget::class);
|
||||
}
|
||||
|
||||
/** Convenience: single artwork target for artwork_share posts */
|
||||
public function artworkTarget(): HasOne
|
||||
{
|
||||
return $this->hasOne(PostTarget::class)->where('target_type', 'artwork');
|
||||
}
|
||||
|
||||
public function reactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostReaction::class);
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostComment::class)->orderBy('created_at');
|
||||
}
|
||||
|
||||
public function saves(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostSave::class);
|
||||
}
|
||||
|
||||
public function reports(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostReport::class);
|
||||
}
|
||||
|
||||
public function hashtags(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostHashtag::class);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Scopes
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Posts visible to any public (non-authenticated) visitor */
|
||||
public function scopePublic($query)
|
||||
{
|
||||
return $query->where('visibility', self::VISIBILITY_PUBLIC);
|
||||
}
|
||||
|
||||
/** Only published posts */
|
||||
public function scopePublished($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PUBLISHED);
|
||||
}
|
||||
|
||||
/** Only scheduled posts */
|
||||
public function scopeScheduled($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_SCHEDULED);
|
||||
}
|
||||
|
||||
/** Posts visible to the given viewer (respects followers-only AND published status) */
|
||||
public function scopeVisibleTo($query, ?int $viewerId)
|
||||
{
|
||||
$query->where('status', self::STATUS_PUBLISHED);
|
||||
|
||||
if (! $viewerId) {
|
||||
return $query->where('visibility', self::VISIBILITY_PUBLIC);
|
||||
}
|
||||
|
||||
return $query->where(function ($q) use ($viewerId) {
|
||||
$q->where('visibility', self::VISIBILITY_PUBLIC)
|
||||
->orWhere('user_id', $viewerId)
|
||||
->orWhere(function ($q2) use ($viewerId) {
|
||||
$q2->where('visibility', self::VISIBILITY_FOLLOWERS)
|
||||
->whereIn('user_id', function ($sub) use ($viewerId) {
|
||||
$sub->select('user_id')
|
||||
->from('user_followers')
|
||||
->where('follower_id', $viewerId);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Scout (Meilisearch)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'body' => strip_tags($this->body ?? ''),
|
||||
'hashtags' => $this->hashtags->pluck('tag')->toArray(),
|
||||
'user_id' => $this->user_id,
|
||||
'type' => $this->type,
|
||||
'visibility' => $this->visibility,
|
||||
'created_at' => $this->created_at?->timestamp,
|
||||
];
|
||||
}
|
||||
|
||||
public function shouldBeSearchable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PUBLISHED
|
||||
&& $this->visibility === self::VISIBILITY_PUBLIC;
|
||||
}
|
||||
}
|
||||
35
app/Models/PostComment.php
Normal file
35
app/Models/PostComment.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PostComment extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'post_comments';
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'user_id',
|
||||
'body',
|
||||
'is_highlighted',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_highlighted' => 'boolean',
|
||||
];
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
31
app/Models/PostHashtag.php
Normal file
31
app/Models/PostHashtag.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PostHashtag extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'post_hashtags';
|
||||
|
||||
protected $fillable = ['post_id', 'user_id', 'tag', 'created_at'];
|
||||
|
||||
// Store tag always lowercase for consistency
|
||||
public function setTagAttribute(string $value): void
|
||||
{
|
||||
$this->attributes['tag'] = mb_strtolower($value);
|
||||
}
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
27
app/Models/PostReaction.php
Normal file
27
app/Models/PostReaction.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PostReaction extends Model
|
||||
{
|
||||
protected $table = 'post_reactions';
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'user_id',
|
||||
'reaction',
|
||||
];
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
29
app/Models/PostReport.php
Normal file
29
app/Models/PostReport.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PostReport extends Model
|
||||
{
|
||||
protected $table = 'post_reports';
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'reporter_user_id',
|
||||
'reason',
|
||||
'message',
|
||||
'status',
|
||||
];
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function reporter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reporter_user_id');
|
||||
}
|
||||
}
|
||||
35
app/Models/PostSave.php
Normal file
35
app/Models/PostSave.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PostSave extends Model
|
||||
{
|
||||
protected $table = 'post_saves';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
const CREATED_AT = 'created_at';
|
||||
const UPDATED_AT = null;
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
41
app/Models/PostTarget.php
Normal file
41
app/Models/PostTarget.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Polymorphic-style target attached to a Post.
|
||||
* For v1: target_type = 'artwork', target_id = artworks.id
|
||||
*/
|
||||
class PostTarget extends Model
|
||||
{
|
||||
protected $table = 'post_targets';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'target_type',
|
||||
'target_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
const CREATED_AT = 'created_at';
|
||||
const UPDATED_AT = null;
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class);
|
||||
}
|
||||
|
||||
/** Resolved Artwork when target_type = 'artwork' */
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class, 'target_id');
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,11 @@ class User extends Authenticatable
|
||||
return $this->hasRole('moderator');
|
||||
}
|
||||
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Post::class)->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
// ─── Meilisearch ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,11 +29,13 @@ class UserProfile extends Model
|
||||
'birthdate',
|
||||
'gender',
|
||||
'website',
|
||||
'auto_post_upload',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'birthdate' => 'date',
|
||||
'avatar_updated_at' => 'datetime',
|
||||
'birthdate' => 'date',
|
||||
'avatar_updated_at'=> 'datetime',
|
||||
'auto_post_upload' => 'boolean',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
Reference in New Issue
Block a user