feat: 게시글 파일 첨부 기능 구현
- File 모델 추가 (Polymorphic 관계) - Post 모델에 files() MorphMany 관계 추가 - PostService 파일 업로드/삭제/다운로드 메서드 추가 - PostController 파일 관련 액션 추가 - 게시글 작성/수정 폼에 드래그앤드롭 파일 업로드 UI - 게시글 상세에 첨부파일 목록 표시 - tenant 디스크 설정 (공유 스토리지) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
309
app/Http/Controllers/PostController.php
Normal file
309
app/Http/Controllers/PostController.php
Normal file
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Boards\Board;
|
||||
use App\Models\Boards\Post;
|
||||
use App\Services\PostService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PostService $postService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 게시글 목록
|
||||
*/
|
||||
public function index(Board $board, Request $request): View
|
||||
{
|
||||
$filters = $request->only(['search', 'is_notice']);
|
||||
$posts = $this->postService->getPosts($board->id, $filters, 15);
|
||||
$notices = $this->postService->getNotices($board->id, 5);
|
||||
$stats = $this->postService->getBoardPostStats($board->id);
|
||||
|
||||
return view('posts.index', compact('board', 'posts', 'notices', 'stats', 'filters'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 작성 폼
|
||||
*/
|
||||
public function create(Board $board): View
|
||||
{
|
||||
$fields = $board->fields;
|
||||
|
||||
return view('posts.create', compact('board', 'fields'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 저장
|
||||
*/
|
||||
public function store(Board $board, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'nullable|string',
|
||||
'is_notice' => 'nullable|boolean',
|
||||
'is_secret' => 'nullable|boolean',
|
||||
'files' => 'nullable|array',
|
||||
'files.*' => 'file|max:'.($board->max_file_size),
|
||||
]);
|
||||
|
||||
// 커스텀 필드 값
|
||||
$customFields = $request->input('custom_fields', []);
|
||||
|
||||
// 커스텀 필드 유효성 검사
|
||||
foreach ($board->fields as $field) {
|
||||
if ($field->is_required && empty($customFields[$field->field_key])) {
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors([$field->field_key => "{$field->name}은(는) 필수입니다."]);
|
||||
}
|
||||
}
|
||||
|
||||
$validated['is_notice'] = $request->boolean('is_notice');
|
||||
$validated['is_secret'] = $request->boolean('is_secret');
|
||||
|
||||
$post = $this->postService->createPost($board, $validated, $customFields);
|
||||
|
||||
// 파일 업로드 처리
|
||||
if ($request->hasFile('files') && $board->allow_files) {
|
||||
try {
|
||||
$this->postService->uploadFiles($post, $request->file('files'));
|
||||
} catch (\Exception $e) {
|
||||
// 파일 업로드 실패해도 게시글은 저장됨 - 경고 메시지만 표시
|
||||
return redirect()
|
||||
->route('boards.posts.show', [$board, $post])
|
||||
->with('warning', '게시글이 작성되었으나 일부 파일 업로드에 실패했습니다: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('boards.posts.show', [$board, $post])
|
||||
->with('success', '게시글이 작성되었습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 상세
|
||||
*/
|
||||
public function show(Board $board, Post $post): View|RedirectResponse
|
||||
{
|
||||
// 게시판 일치 확인
|
||||
if ($post->board_id !== $board->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// 비밀글 접근 권한 확인
|
||||
if (! $post->canAccess(auth()->user())) {
|
||||
return back()->with('error', '비밀글은 작성자만 볼 수 있습니다.');
|
||||
}
|
||||
|
||||
// 조회수 증가
|
||||
$this->postService->incrementViews($post);
|
||||
|
||||
// 이전/다음 글
|
||||
$adjacent = $this->postService->getAdjacentPosts($post);
|
||||
|
||||
// 커스텀 필드 값
|
||||
$customFieldValues = $post->getCustomFieldsArray();
|
||||
|
||||
return view('posts.show', compact('board', 'post', 'adjacent', 'customFieldValues'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 수정 폼
|
||||
*/
|
||||
public function edit(Board $board, Post $post): View|RedirectResponse
|
||||
{
|
||||
// 게시판 일치 확인
|
||||
if ($post->board_id !== $board->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// 수정 권한 확인 (작성자 또는 관리자)
|
||||
if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) {
|
||||
return back()->with('error', '수정 권한이 없습니다.');
|
||||
}
|
||||
|
||||
$fields = $board->fields;
|
||||
$customFieldValues = $post->getCustomFieldsArray();
|
||||
|
||||
return view('posts.edit', compact('board', 'post', 'fields', 'customFieldValues'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 수정 저장
|
||||
*/
|
||||
public function update(Board $board, Post $post, Request $request): RedirectResponse
|
||||
{
|
||||
// 게시판 일치 확인
|
||||
if ($post->board_id !== $board->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// 수정 권한 확인
|
||||
if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) {
|
||||
return back()->with('error', '수정 권한이 없습니다.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'nullable|string',
|
||||
'is_notice' => 'nullable|boolean',
|
||||
'is_secret' => 'nullable|boolean',
|
||||
'files' => 'nullable|array',
|
||||
'files.*' => 'file|max:'.($board->max_file_size),
|
||||
]);
|
||||
|
||||
// 커스텀 필드 값
|
||||
$customFields = $request->input('custom_fields', []);
|
||||
|
||||
// 커스텀 필드 유효성 검사
|
||||
foreach ($board->fields as $field) {
|
||||
if ($field->is_required && empty($customFields[$field->field_key])) {
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors([$field->field_key => "{$field->name}은(는) 필수입니다."]);
|
||||
}
|
||||
}
|
||||
|
||||
$validated['is_notice'] = $request->boolean('is_notice');
|
||||
$validated['is_secret'] = $request->boolean('is_secret');
|
||||
|
||||
$this->postService->updatePost($post, $validated, $customFields);
|
||||
|
||||
// 파일 업로드 처리
|
||||
if ($request->hasFile('files') && $board->allow_files) {
|
||||
try {
|
||||
$this->postService->uploadFiles($post, $request->file('files'));
|
||||
} catch (\Exception $e) {
|
||||
return redirect()
|
||||
->route('boards.posts.show', [$board, $post])
|
||||
->with('warning', '게시글이 수정되었으나 일부 파일 업로드에 실패했습니다: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('boards.posts.show', [$board, $post])
|
||||
->with('success', '게시글이 수정되었습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 삭제
|
||||
*/
|
||||
public function destroy(Board $board, Post $post): RedirectResponse
|
||||
{
|
||||
// 게시판 일치 확인
|
||||
if ($post->board_id !== $board->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// 삭제 권한 확인
|
||||
if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) {
|
||||
return back()->with('error', '삭제 권한이 없습니다.');
|
||||
}
|
||||
|
||||
// 첨부 파일 삭제
|
||||
$this->postService->deleteAllFiles($post);
|
||||
|
||||
$this->postService->deletePost($post);
|
||||
|
||||
return redirect()
|
||||
->route('boards.posts.index', $board)
|
||||
->with('success', '게시글이 삭제되었습니다.');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// File Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 파일 업로드 (AJAX)
|
||||
*/
|
||||
public function uploadFiles(Board $board, Post $post, Request $request): JsonResponse
|
||||
{
|
||||
// 게시판 일치 확인
|
||||
if ($post->board_id !== $board->id) {
|
||||
return response()->json(['success' => false, 'message' => '게시글을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
// 수정 권한 확인
|
||||
if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) {
|
||||
return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403);
|
||||
}
|
||||
|
||||
// 파일 첨부 허용 여부
|
||||
if (! $board->allow_files) {
|
||||
return response()->json(['success' => false, 'message' => '이 게시판은 파일 첨부가 허용되지 않습니다.'], 400);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'files' => 'required|array',
|
||||
'files.*' => 'file|max:'.($board->max_file_size),
|
||||
]);
|
||||
|
||||
try {
|
||||
$uploadedFiles = $this->postService->uploadFiles($post, $request->file('files'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => count($uploadedFiles).'개의 파일이 업로드되었습니다.',
|
||||
'files' => collect($uploadedFiles)->map(fn ($file) => [
|
||||
'id' => $file->id,
|
||||
'name' => $file->display_name ?? $file->original_name,
|
||||
'size' => $file->getFormattedSize(),
|
||||
'type' => $file->file_type,
|
||||
]),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 다운로드
|
||||
*/
|
||||
public function downloadFile(Board $board, Post $post, int $fileId): StreamedResponse|RedirectResponse
|
||||
{
|
||||
// 게시판 일치 확인
|
||||
if ($post->board_id !== $board->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// 비밀글 접근 권한 확인
|
||||
if (! $post->canAccess(auth()->user())) {
|
||||
return back()->with('error', '파일에 접근할 수 없습니다.');
|
||||
}
|
||||
|
||||
return $this->postService->downloadFile($post, $fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제 (AJAX)
|
||||
*/
|
||||
public function deleteFile(Board $board, Post $post, int $fileId): JsonResponse
|
||||
{
|
||||
// 게시판 일치 확인
|
||||
if ($post->board_id !== $board->id) {
|
||||
return response()->json(['success' => false, 'message' => '게시글을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
// 삭제 권한 확인 (작성자 또는 관리자)
|
||||
if ($post->user_id !== auth()->id() && ! auth()->user()->hasRole(['admin', 'super-admin'])) {
|
||||
return response()->json(['success' => false, 'message' => '권한이 없습니다.'], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->postService->deleteFile($post, $fileId);
|
||||
|
||||
return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,11 @@ public function fields(): HasMany
|
||||
->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Post::class, 'board_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
|
||||
222
app/Models/Boards/File.php
Normal file
222
app/Models/Boards/File.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Boards;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 파일 모델 (Polymorphic)
|
||||
*
|
||||
* @property int $id
|
||||
* @property int|null $tenant_id
|
||||
* @property int|null $folder_id
|
||||
* @property bool $is_temp
|
||||
* @property string $file_path
|
||||
* @property string|null $display_name
|
||||
* @property string|null $stored_name
|
||||
* @property string|null $original_name
|
||||
* @property int $file_size
|
||||
* @property string|null $mime_type
|
||||
* @property string|null $file_type
|
||||
* @property int|null $fileable_id
|
||||
* @property string|null $fileable_type
|
||||
* @property int|null $uploaded_by
|
||||
*/
|
||||
class File extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'files';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'folder_id',
|
||||
'is_temp',
|
||||
'file_path',
|
||||
'display_name',
|
||||
'stored_name',
|
||||
'original_name',
|
||||
'file_name',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
'file_type',
|
||||
'fileable_id',
|
||||
'fileable_type',
|
||||
'description',
|
||||
'uploaded_by',
|
||||
'deleted_by',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_temp' => 'boolean',
|
||||
'file_size' => 'integer',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// Relationships
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Polymorphic 관계 - 연결된 모델 (Post, Product 등)
|
||||
*/
|
||||
public function fileable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 업로더
|
||||
*/
|
||||
public function uploader(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 스토리지 전체 경로
|
||||
*/
|
||||
public function getStoragePath(): string
|
||||
{
|
||||
return Storage::disk('tenant')->path($this->file_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 존재 여부
|
||||
*/
|
||||
public function existsInStorage(): bool
|
||||
{
|
||||
return Storage::disk('tenant')->exists($this->file_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다운로드 응답
|
||||
*/
|
||||
public function download()
|
||||
{
|
||||
if (! $this->existsInStorage()) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$fileName = $this->display_name ?? $this->original_name ?? $this->file_name;
|
||||
|
||||
return response()->download($this->getStoragePath(), $fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 URL (public 접근용)
|
||||
*/
|
||||
public function getUrl(): ?string
|
||||
{
|
||||
if (! $this->existsInStorage()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::disk('tenant')->url($this->file_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 크기 포맷팅
|
||||
*/
|
||||
public function getFormattedSize(): string
|
||||
{
|
||||
$bytes = $this->file_size;
|
||||
|
||||
if ($bytes >= 1073741824) {
|
||||
return number_format($bytes / 1073741824, 2).' GB';
|
||||
} elseif ($bytes >= 1048576) {
|
||||
return number_format($bytes / 1048576, 2).' MB';
|
||||
} elseif ($bytes >= 1024) {
|
||||
return number_format($bytes / 1024, 2).' KB';
|
||||
}
|
||||
|
||||
return $bytes.' bytes';
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 확장자
|
||||
*/
|
||||
public function getExtension(): string
|
||||
{
|
||||
$fileName = $this->stored_name ?? $this->file_name ?? $this->original_name;
|
||||
|
||||
return pathinfo($fileName, PATHINFO_EXTENSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 여부
|
||||
*/
|
||||
public function isImage(): bool
|
||||
{
|
||||
return str_starts_with($this->mime_type ?? '', 'image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft Delete with user tracking
|
||||
*/
|
||||
public function softDeleteFile(int $userId): void
|
||||
{
|
||||
$this->deleted_by = $userId;
|
||||
$this->save();
|
||||
$this->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 완전 삭제 (물리 파일 포함)
|
||||
*/
|
||||
public function permanentDelete(): void
|
||||
{
|
||||
if ($this->existsInStorage()) {
|
||||
Storage::disk('tenant')->delete($this->file_path);
|
||||
}
|
||||
|
||||
$this->forceDelete();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scopes
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 특정 모델의 파일들
|
||||
*/
|
||||
public function scopeForModel($query, string $type, int $id)
|
||||
{
|
||||
return $query->where('fileable_type', $type)
|
||||
->where('fileable_id', $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 파일만
|
||||
*/
|
||||
public function scopeTemp($query)
|
||||
{
|
||||
return $query->where('is_temp', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 파일 제외
|
||||
*/
|
||||
public function scopeNonTemp($query)
|
||||
{
|
||||
return $query->where('is_temp', false);
|
||||
}
|
||||
}
|
||||
211
app/Models/Boards/Post.php
Normal file
211
app/Models/Boards/Post.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Boards;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 게시글 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property int|null $tenant_id
|
||||
* @property int $board_id
|
||||
* @property int $user_id
|
||||
* @property string $title
|
||||
* @property string|null $content
|
||||
* @property string $editor_type
|
||||
* @property string|null $ip_address
|
||||
* @property bool $is_notice
|
||||
* @property bool $is_secret
|
||||
* @property int $views
|
||||
* @property string $status
|
||||
*/
|
||||
class Post extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'posts';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'board_id',
|
||||
'user_id',
|
||||
'title',
|
||||
'content',
|
||||
'editor_type',
|
||||
'ip_address',
|
||||
'is_notice',
|
||||
'is_secret',
|
||||
'views',
|
||||
'status',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_notice' => 'boolean',
|
||||
'is_secret' => 'boolean',
|
||||
'views' => 'integer',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'is_notice' => false,
|
||||
'is_secret' => false,
|
||||
'views' => 0,
|
||||
'status' => 'published',
|
||||
'editor_type' => 'wysiwyg',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// Scopes
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 특정 게시판의 글
|
||||
*/
|
||||
public function scopeOfBoard(Builder $query, int $boardId): Builder
|
||||
{
|
||||
return $query->where('board_id', $boardId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공지사항만
|
||||
*/
|
||||
public function scopeNotices(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_notice', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 글만 (공지 제외)
|
||||
*/
|
||||
public function scopeRegular(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_notice', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시된 글만
|
||||
*/
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', 'published');
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀글이 아닌 것만
|
||||
*/
|
||||
public function scopePublic(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_secret', false);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Relationships
|
||||
// =========================================================================
|
||||
|
||||
public function board(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Board::class, 'board_id');
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function customFieldValues(): HasMany
|
||||
{
|
||||
return $this->hasMany(PostCustomFieldValue::class, 'post_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 (Polymorphic)
|
||||
*/
|
||||
public function files(): MorphMany
|
||||
{
|
||||
return $this->morphMany(File::class, 'fileable');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 조회수 증가
|
||||
*/
|
||||
public function incrementViews(): void
|
||||
{
|
||||
$this->increment('views');
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 필드 값 가져오기
|
||||
*/
|
||||
public function getCustomFieldValue(string $fieldKey): ?string
|
||||
{
|
||||
$fieldValue = $this->customFieldValues()
|
||||
->whereHas('field', fn ($q) => $q->where('field_key', $fieldKey))
|
||||
->first();
|
||||
|
||||
return $fieldValue?->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 필드 값들을 배열로 가져오기
|
||||
*/
|
||||
public function getCustomFieldsArray(): array
|
||||
{
|
||||
return $this->customFieldValues()
|
||||
->with('field')
|
||||
->get()
|
||||
->mapWithKeys(fn ($v) => [$v->field->field_key => $v->value])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀글 접근 가능 여부
|
||||
*/
|
||||
public function canAccess(?User $user): bool
|
||||
{
|
||||
// 비밀글이 아니면 모두 접근 가능
|
||||
if (! $this->is_secret) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 비로그인이면 접근 불가
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 작성자 본인이면 접근 가능
|
||||
if ($this->user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 관리자면 접근 가능
|
||||
if ($user->hasRole(['admin', 'super-admin'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
41
app/Models/Boards/PostCustomFieldValue.php
Normal file
41
app/Models/Boards/PostCustomFieldValue.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Boards;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 게시글 커스텀 필드 값 (EAV 값 저장)
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $post_id
|
||||
* @property int $field_id
|
||||
* @property string|null $value
|
||||
*/
|
||||
class PostCustomFieldValue extends Model
|
||||
{
|
||||
protected $table = 'post_custom_field_values';
|
||||
|
||||
protected $fillable = [
|
||||
'post_id',
|
||||
'field_id',
|
||||
'value',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// Relationships
|
||||
// =========================================================================
|
||||
|
||||
public function post(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Post::class, 'post_id');
|
||||
}
|
||||
|
||||
public function field(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BoardSetting::class, 'field_id');
|
||||
}
|
||||
}
|
||||
@@ -174,4 +174,43 @@ public function canAccessMng(): bool
|
||||
{
|
||||
return $this->belongsToHQ() && $this->is_active;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 역할 보유 여부 확인
|
||||
*
|
||||
* @param string|array $roles 역할명 또는 역할명 배열
|
||||
*/
|
||||
public function hasRole(string|array $roles): bool
|
||||
{
|
||||
// 슈퍼관리자는 모든 역할 보유
|
||||
if ($this->is_super_admin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$roles = is_array($roles) ? $roles : [$roles];
|
||||
|
||||
// 현재 테넌트 기준 역할 확인
|
||||
$currentTenant = $this->currentTenant();
|
||||
if (! $currentTenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userRoles = $this->getRolesForTenant($currentTenant->id);
|
||||
|
||||
foreach ($roles as $roleName) {
|
||||
if ($userRoles->contains('name', $roleName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 역할 보유 여부 (admin 또는 super-admin)
|
||||
*/
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->is_super_admin || $this->hasRole(['admin', 'super-admin']);
|
||||
}
|
||||
}
|
||||
|
||||
384
app/Services/PostService.php
Normal file
384
app/Services/PostService.php
Normal file
@@ -0,0 +1,384 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Boards\Board;
|
||||
use App\Models\Boards\File;
|
||||
use App\Models\Boards\Post;
|
||||
use App\Models\Boards\PostCustomFieldValue;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PostService
|
||||
{
|
||||
/**
|
||||
* 게시글 목록 조회 (페이지네이션)
|
||||
*/
|
||||
public function getPosts(int $boardId, array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$query = Post::query()
|
||||
->ofBoard($boardId)
|
||||
->with(['author', 'board'])
|
||||
->published();
|
||||
|
||||
// 검색
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('title', 'like', "%{$search}%")
|
||||
->orWhere('content', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 공지사항 필터
|
||||
if (isset($filters['is_notice'])) {
|
||||
$query->where('is_notice', $filters['is_notice']);
|
||||
}
|
||||
|
||||
// 정렬: 공지사항 먼저, 그 다음 최신순
|
||||
$query->orderByDesc('is_notice')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공지사항 목록 (상단 고정용)
|
||||
*/
|
||||
public function getNotices(int $boardId, int $limit = 5): Collection
|
||||
{
|
||||
return Post::query()
|
||||
->ofBoard($boardId)
|
||||
->published()
|
||||
->notices()
|
||||
->with('author')
|
||||
->orderByDesc('created_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 상세 조회
|
||||
*/
|
||||
public function getPostById(int $id, bool $withTrashed = false): ?Post
|
||||
{
|
||||
$query = Post::query()
|
||||
->with(['board.fields', 'author', 'customFieldValues.field']);
|
||||
|
||||
if ($withTrashed) {
|
||||
$query->withTrashed();
|
||||
}
|
||||
|
||||
return $query->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 생성
|
||||
*/
|
||||
public function createPost(Board $board, array $data, array $customFields = []): Post
|
||||
{
|
||||
return DB::transaction(function () use ($board, $data, $customFields) {
|
||||
// 기본 데이터 설정
|
||||
$data['board_id'] = $board->id;
|
||||
// 시스템 게시판(tenant_id=null)인 경우 사용자의 현재 테넌트 사용
|
||||
$data['tenant_id'] = $board->tenant_id ?? auth()->user()->currentTenant()?->id;
|
||||
$data['user_id'] = auth()->id();
|
||||
$data['created_by'] = auth()->id();
|
||||
$data['editor_type'] = $board->editor_type;
|
||||
$data['ip_address'] = request()->ip();
|
||||
|
||||
$post = Post::create($data);
|
||||
|
||||
// 커스텀 필드 저장
|
||||
$this->saveCustomFields($post, $board, $customFields);
|
||||
|
||||
return $post->load(['board', 'author', 'customFieldValues.field']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 수정
|
||||
*/
|
||||
public function updatePost(Post $post, array $data, array $customFields = []): Post
|
||||
{
|
||||
return DB::transaction(function () use ($post, $data, $customFields) {
|
||||
$data['updated_by'] = auth()->id();
|
||||
|
||||
$post->update($data);
|
||||
|
||||
// 커스텀 필드 업데이트
|
||||
$this->saveCustomFields($post, $post->board, $customFields);
|
||||
|
||||
return $post->fresh(['board', 'author', 'customFieldValues.field']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 삭제 (Soft Delete)
|
||||
*/
|
||||
public function deletePost(Post $post): bool
|
||||
{
|
||||
$post->deleted_by = auth()->id();
|
||||
$post->save();
|
||||
|
||||
return $post->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 영구 삭제
|
||||
*/
|
||||
public function forceDeletePost(Post $post): bool
|
||||
{
|
||||
// 커스텀 필드 값 삭제
|
||||
$post->customFieldValues()->delete();
|
||||
|
||||
return $post->forceDelete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 조회수 증가
|
||||
*/
|
||||
public function incrementViews(Post $post): void
|
||||
{
|
||||
$post->incrementViews();
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 필드 저장
|
||||
*/
|
||||
private function saveCustomFields(Post $post, Board $board, array $customFields): void
|
||||
{
|
||||
// 기존 값 삭제 후 새로 저장 (upsert 대신 간단한 방식)
|
||||
$post->customFieldValues()->delete();
|
||||
|
||||
$boardFields = $board->fields;
|
||||
|
||||
foreach ($boardFields as $field) {
|
||||
$value = $customFields[$field->field_key] ?? null;
|
||||
|
||||
if ($value !== null && $value !== '') {
|
||||
PostCustomFieldValue::create([
|
||||
'post_id' => $post->id,
|
||||
'field_id' => $field->id,
|
||||
'value' => is_array($value) ? json_encode($value) : $value,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전/다음 글 조회
|
||||
*/
|
||||
public function getAdjacentPosts(Post $post): array
|
||||
{
|
||||
$prev = Post::query()
|
||||
->ofBoard($post->board_id)
|
||||
->published()
|
||||
->where('id', '<', $post->id)
|
||||
->orderByDesc('id')
|
||||
->first(['id', 'title']);
|
||||
|
||||
$next = Post::query()
|
||||
->ofBoard($post->board_id)
|
||||
->published()
|
||||
->where('id', '>', $post->id)
|
||||
->orderBy('id')
|
||||
->first(['id', 'title']);
|
||||
|
||||
return compact('prev', 'next');
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판 통계
|
||||
*/
|
||||
public function getBoardPostStats(int $boardId): array
|
||||
{
|
||||
return [
|
||||
'total' => Post::ofBoard($boardId)->count(),
|
||||
'published' => Post::ofBoard($boardId)->published()->count(),
|
||||
'notices' => Post::ofBoard($boardId)->notices()->count(),
|
||||
'today' => Post::ofBoard($boardId)->whereDate('created_at', today())->count(),
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// File Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 파일 업로드 및 게시글 연결
|
||||
*/
|
||||
public function uploadFiles(Post $post, array $files): array
|
||||
{
|
||||
$board = $post->board;
|
||||
$uploadedFiles = [];
|
||||
|
||||
// 파일 첨부 허용 여부 확인
|
||||
if (! $board->allow_files) {
|
||||
throw new \Exception('이 게시판은 파일 첨부가 허용되지 않습니다.');
|
||||
}
|
||||
|
||||
// 최대 파일 개수 확인
|
||||
$currentCount = $post->files()->count();
|
||||
$maxCount = $board->max_file_count;
|
||||
|
||||
if ($currentCount + count($files) > $maxCount) {
|
||||
throw new \Exception("최대 {$maxCount}개의 파일만 첨부할 수 있습니다.");
|
||||
}
|
||||
|
||||
$tenantId = $post->tenant_id;
|
||||
$year = now()->format('Y');
|
||||
$month = now()->format('m');
|
||||
// Path pattern: {tenant_id}/{folder_key}/{year}/{month}/{stored_name}
|
||||
// (tenant disk root already includes /tenants/)
|
||||
$basePath = "{$tenantId}/posts/{$year}/{$month}";
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (! $file instanceof UploadedFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 파일 크기 확인 (KB 단위로 저장됨)
|
||||
$fileSizeKB = $file->getSize() / 1024;
|
||||
if ($fileSizeKB > $board->max_file_size) {
|
||||
$maxSizeMB = round($board->max_file_size / 1024, 1);
|
||||
throw new \Exception("파일 크기는 {$maxSizeMB}MB를 초과할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 저장 파일명 생성
|
||||
$storedName = Str::uuid().'.'.$file->getClientOriginalExtension();
|
||||
$filePath = "{$basePath}/{$storedName}";
|
||||
|
||||
// 파일 저장
|
||||
Storage::disk('tenant')->put($filePath, file_get_contents($file));
|
||||
|
||||
// DB 레코드 생성
|
||||
$fileRecord = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'is_temp' => false,
|
||||
'file_path' => $filePath,
|
||||
'display_name' => $file->getClientOriginalName(),
|
||||
'stored_name' => $storedName,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'file_name' => $file->getClientOriginalName(),
|
||||
'file_size' => $file->getSize(),
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_type' => $this->getFileType($file->getMimeType()),
|
||||
'fileable_type' => Post::class,
|
||||
'fileable_id' => $post->id,
|
||||
'uploaded_by' => auth()->id(),
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$uploadedFiles[] = $fileRecord;
|
||||
}
|
||||
|
||||
return $uploadedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 파일 삭제
|
||||
*/
|
||||
public function deleteFile(Post $post, int $fileId): bool
|
||||
{
|
||||
$file = $post->files()->find($fileId);
|
||||
|
||||
if (! $file) {
|
||||
throw new \Exception('파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 물리 파일 삭제
|
||||
if ($file->existsInStorage()) {
|
||||
Storage::disk('tenant')->delete($file->file_path);
|
||||
}
|
||||
|
||||
// 소프트 삭제
|
||||
$file->deleted_by = auth()->id();
|
||||
$file->save();
|
||||
|
||||
return $file->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 파일 전체 삭제 (게시글 삭제 시)
|
||||
*/
|
||||
public function deleteAllFiles(Post $post): void
|
||||
{
|
||||
foreach ($post->files as $file) {
|
||||
$file->deleted_by = auth()->id();
|
||||
$file->save();
|
||||
$file->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 파일 영구 삭제 (게시글 영구 삭제 시)
|
||||
*/
|
||||
public function forceDeleteAllFiles(Post $post): void
|
||||
{
|
||||
foreach ($post->files()->withTrashed()->get() as $file) {
|
||||
$file->permanentDelete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 타입 추출
|
||||
*/
|
||||
private function getFileType(?string $mimeType): string
|
||||
{
|
||||
if (! $mimeType) {
|
||||
return 'other';
|
||||
}
|
||||
|
||||
if (str_starts_with($mimeType, 'image/')) {
|
||||
return 'image';
|
||||
}
|
||||
if (str_starts_with($mimeType, 'video/')) {
|
||||
return 'video';
|
||||
}
|
||||
if (str_starts_with($mimeType, 'audio/')) {
|
||||
return 'audio';
|
||||
}
|
||||
if (str_contains($mimeType, 'pdf')) {
|
||||
return 'pdf';
|
||||
}
|
||||
if (str_contains($mimeType, 'word') || str_contains($mimeType, 'document')) {
|
||||
return 'document';
|
||||
}
|
||||
if (str_contains($mimeType, 'excel') || str_contains($mimeType, 'spreadsheet')) {
|
||||
return 'spreadsheet';
|
||||
}
|
||||
if (str_contains($mimeType, 'zip') || str_contains($mimeType, 'rar') || str_contains($mimeType, 'compressed')) {
|
||||
return 'archive';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 파일 목록 조회
|
||||
*/
|
||||
public function getPostFiles(Post $post): Collection
|
||||
{
|
||||
return $post->files()->orderBy('created_at')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 다운로드
|
||||
*/
|
||||
public function downloadFile(Post $post, int $fileId)
|
||||
{
|
||||
$file = $post->files()->find($fileId);
|
||||
|
||||
if (! $file) {
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return $file->download();
|
||||
}
|
||||
}
|
||||
163
claudedocs/2025-12-02_file-attachment-feature.md
Normal file
163
claudedocs/2025-12-02_file-attachment-feature.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# 게시글 파일 첨부 기능 구현 + 공유 스토리지 설정
|
||||
|
||||
**작업일**: 2025-12-02
|
||||
**저장소**: MNG, API, Docker
|
||||
**워크플로우**: code-workflow (분석→수정→검증→정리→커밋)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
게시판 시스템에 파일 첨부 기능을 추가했습니다. 기존의 `board_files` 테이블 대신 범용 `files` 테이블의 polymorphic 관계를 활용합니다.
|
||||
|
||||
**추가 작업**: API와 MNG 간 파일 공유를 위한 Docker 공유 볼륨 설정 및 S3 마이그레이션 용이한 구조로 변경
|
||||
|
||||
## 변경 파일
|
||||
|
||||
### Docker 설정
|
||||
| 파일 | 작업 | 설명 |
|
||||
|------|------|------|
|
||||
| `docker/docker-compose.yml` | 수정 | sam_storage 공유 볼륨 추가 (api, admin, mng) |
|
||||
|
||||
### API 저장소
|
||||
| 파일 | 작업 | 설명 |
|
||||
|------|------|------|
|
||||
| `config/filesystems.php` | 수정 | tenant 디스크 공유 경로 + S3 설정 |
|
||||
| `database/migrations/2025_12_02_000238_drop_board_files_table.php` | 생성 | board_files 테이블 삭제 |
|
||||
|
||||
### MNG 저장소
|
||||
| 파일 | 작업 | 설명 |
|
||||
|------|------|------|
|
||||
| `app/Models/Boards/File.php` | 생성 | Polymorphic 파일 모델 |
|
||||
| `app/Models/Boards/Post.php` | 수정 | files() MorphMany 관계 추가 |
|
||||
| `app/Services/PostService.php` | 수정 | 파일 업로드/삭제/다운로드 + 경로 패턴 수정 |
|
||||
| `app/Http/Controllers/PostController.php` | 수정 | 파일 관련 액션 추가 |
|
||||
| `resources/views/posts/create.blade.php` | 수정 | 파일 업로드 UI |
|
||||
| `resources/views/posts/show.blade.php` | 수정 | 첨부파일 목록 표시 |
|
||||
| `resources/views/posts/edit.blade.php` | 수정 | 기존 파일 관리 + 새 파일 업로드 |
|
||||
| `routes/web.php` | 수정 | 파일 라우트 추가 |
|
||||
| `config/filesystems.php` | 수정 | tenant 디스크 공유 경로 + S3 설정 |
|
||||
| `storage/app/tenants/.gitignore` | 생성 | 업로드 파일 Git 제외 |
|
||||
|
||||
## 기술 상세
|
||||
|
||||
### Polymorphic 관계
|
||||
```php
|
||||
// Post -> files() MorphMany
|
||||
$post->files; // Collection of File models
|
||||
|
||||
// File -> fileable() MorphTo
|
||||
$file->fileable; // Returns Post model
|
||||
```
|
||||
|
||||
### 파일 저장 경로 (공유 스토리지)
|
||||
```
|
||||
Docker 볼륨: sam_storage → /var/www/shared-storage
|
||||
실제 경로: /var/www/shared-storage/tenants/{tenant_id}/posts/{year}/{month}/{stored_name}
|
||||
DB 저장: {tenant_id}/posts/{year}/{month}/{stored_name}
|
||||
```
|
||||
|
||||
### 공유 스토리지 아키텍처
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Docker Volume: sam_storage │
|
||||
│ /var/www/shared-storage/tenants │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ API │ │ MNG │ │ Admin │ │
|
||||
│ │Container│ │Container│ │Container│ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────┴────────────────┘ │
|
||||
│ │ │
|
||||
│ Storage::disk('tenant') │
|
||||
│ │ │
|
||||
│ ┌─────────────────────┴─────────────────────┐ │
|
||||
│ │ /var/www/shared-storage/tenants │ │
|
||||
│ │ ├── {tenant_id}/ │ │
|
||||
│ │ │ ├── posts/2025/12/xxx.pdf │ │
|
||||
│ │ │ ├── products/2025/12/yyy.jpg │ │
|
||||
│ │ │ └── documents/2025/12/zzz.docx │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### S3 마이그레이션 방법
|
||||
```bash
|
||||
# .env 설정 변경만으로 S3 전환 가능
|
||||
TENANT_STORAGE_DRIVER=s3
|
||||
AWS_ACCESS_KEY_ID=your_key
|
||||
AWS_SECRET_ACCESS_KEY=your_secret
|
||||
AWS_DEFAULT_REGION=ap-northeast-2
|
||||
AWS_BUCKET=sam-storage
|
||||
```
|
||||
|
||||
### 게시판 설정
|
||||
- `allow_files`: 파일 첨부 허용 여부
|
||||
- `max_file_count`: 최대 파일 개수
|
||||
- `max_file_size`: 최대 파일 크기 (KB)
|
||||
|
||||
### 새 라우트
|
||||
```
|
||||
GET boards/{board}/posts/{post}/files/{fileId}/download # 다운로드
|
||||
POST boards/{board}/posts/{post}/files # 업로드 (AJAX)
|
||||
DELETE boards/{board}/posts/{post}/files/{fileId} # 삭제 (AJAX)
|
||||
```
|
||||
|
||||
### File 모델 주요 메서드
|
||||
- `fileable()`: Polymorphic 관계
|
||||
- `download()`: StreamedResponse 반환
|
||||
- `getFormattedSize()`: 사람이 읽기 쉬운 파일 크기
|
||||
- `isImage()`: 이미지 파일 여부
|
||||
- `permanentDelete()`: 실제 파일 + DB 레코드 삭제
|
||||
|
||||
### PostService 주요 메서드
|
||||
- `uploadFiles(Post, array)`: 파일 업로드 및 저장
|
||||
- `deleteFile(Post, fileId)`: 파일 소프트 삭제
|
||||
- `downloadFile(Post, fileId)`: 파일 다운로드 응답
|
||||
|
||||
## UI 기능
|
||||
|
||||
### 글쓰기 (create.blade.php)
|
||||
- 드래그앤드롭 파일 업로드 영역 ✅
|
||||
- 파일 선택 시 미리보기 목록
|
||||
- 파일 개수/크기 제한 클라이언트 검증
|
||||
- **드래그앤드롭 JS 이벤트** (dragenter, dragover, dragleave, drop)
|
||||
- **시각적 피드백** (드래그 시 테두리 파란색 변경)
|
||||
|
||||
### 글보기 (show.blade.php)
|
||||
- 첨부파일 섹션 (파일 있을 때만 표시)
|
||||
- 이미지/문서 아이콘 구분
|
||||
- 다운로드 버튼
|
||||
|
||||
### 글수정 (edit.blade.php)
|
||||
- 기존 첨부파일 목록 (삭제 버튼 포함)
|
||||
- AJAX 파일 삭제 (확인 후 즉시 반영)
|
||||
- 새 파일 추가 영역
|
||||
- **드래그앤드롭 JS 이벤트** (dragenter, dragover, dragleave, drop)
|
||||
- **시각적 피드백** (드래그 시 테두리 파란색 변경)
|
||||
- **기존 파일 개수 고려** (최대 파일 수 체크 시 기존 파일 포함)
|
||||
|
||||
## 검증 결과
|
||||
|
||||
- [x] PHP 문법 검증 통과
|
||||
- [x] 라우트 등록 확인
|
||||
- [x] tenant 디스크 설정 확인
|
||||
- [x] Pint 코드 포맷팅 완료
|
||||
|
||||
## 다음 단계 (커밋)
|
||||
|
||||
### API 저장소
|
||||
```bash
|
||||
cd /Users/kent/Works/@KD_SAM/SAM/api
|
||||
git add .
|
||||
git commit -m "feat(SAM-API): board_files 테이블 삭제 마이그레이션"
|
||||
```
|
||||
|
||||
### MNG 저장소
|
||||
```bash
|
||||
cd /Users/kent/Works/@KD_SAM/SAM/mng
|
||||
git add .
|
||||
git commit -m "feat(SAM-MNG): 게시글 파일 첨부 기능 구현"
|
||||
```
|
||||
@@ -38,6 +38,24 @@
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
/*
|
||||
|------------------------------------------------------------------
|
||||
| Tenant Storage Disk (Shared across API/MNG/Admin)
|
||||
|------------------------------------------------------------------
|
||||
|
|
||||
| This disk is shared across all applications via Docker volume.
|
||||
| - Local (Docker): /var/www/shared-storage/tenants
|
||||
| - S3: Set TENANT_STORAGE_DISK=s3 in .env
|
||||
|
|
||||
*/
|
||||
'tenant' => [
|
||||
'driver' => env('TENANT_STORAGE_DRIVER', 'local'),
|
||||
'root' => env('TENANT_STORAGE_PATH', '/var/www/shared-storage/tenants'),
|
||||
'visibility' => 'private',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($boards as $board)
|
||||
<tr class="{{ $board->trashed() ? 'bg-red-50' : '' }}">
|
||||
<tr class="{{ $board->trashed() ? 'bg-red-50' : '' }} hover:bg-gray-50 cursor-pointer"
|
||||
onclick="window.location='{{ route('boards.posts.index', $board->id) }}'">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ $board->id }}
|
||||
</td>
|
||||
@@ -53,12 +54,12 @@
|
||||
삭제됨
|
||||
</span>
|
||||
@elseif($board->is_active)
|
||||
<button onclick="toggleActive({{ $board->id }})"
|
||||
<button onclick="event.stopPropagation(); toggleActive({{ $board->id }})"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 hover:bg-green-200 cursor-pointer">
|
||||
활성
|
||||
</button>
|
||||
@else
|
||||
<button onclick="toggleActive({{ $board->id }})"
|
||||
<button onclick="event.stopPropagation(); toggleActive({{ $board->id }})"
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer">
|
||||
비활성
|
||||
</button>
|
||||
@@ -79,8 +80,9 @@ class="text-red-600 hover:text-red-900">영구삭제</button>
|
||||
@else
|
||||
<!-- 일반 항목 액션 -->
|
||||
<a href="{{ route('boards.edit', $board->id) }}"
|
||||
onclick="event.stopPropagation()"
|
||||
class="text-indigo-600 hover:text-indigo-900">수정</a>
|
||||
<button onclick="confirmDelete({{ $board->id }}, '{{ $board->name }}')"
|
||||
<button onclick="event.stopPropagation(); confirmDelete({{ $board->id }}, '{{ $board->name }}')"
|
||||
class="text-red-600 hover:text-red-900">삭제</button>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
296
resources/views/posts/create.blade.php
Normal file
296
resources/views/posts/create.blade.php
Normal file
@@ -0,0 +1,296 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $board->name . ' - 글쓰기')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">글쓰기</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $board->name }}</p>
|
||||
</div>
|
||||
<a href="{{ route('boards.posts.index', $board) }}" class="text-gray-600 hover:text-gray-900">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 작성 폼 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<form action="{{ route('boards.posts.store', $board) }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<!-- 제목 -->
|
||||
<div class="mb-6">
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
제목 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="title" id="title" value="{{ old('title') }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('title') border-red-500 @enderror"
|
||||
required>
|
||||
@error('title')
|
||||
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- 커스텀 필드들 -->
|
||||
@if($fields->isNotEmpty())
|
||||
<div class="mb-6 border-t border-gray-200 pt-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-4">추가 정보</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
@foreach($fields as $field)
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{{ $field->name }}
|
||||
@if($field->is_required)
|
||||
<span class="text-red-500">*</span>
|
||||
@endif
|
||||
</label>
|
||||
|
||||
@switch($field->field_type)
|
||||
@case('text')
|
||||
<input type="text"
|
||||
name="custom_fields[{{ $field->field_key }}]"
|
||||
value="{{ old('custom_fields.' . $field->field_key) }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
{{ $field->is_required ? 'required' : '' }}>
|
||||
@break
|
||||
|
||||
@case('number')
|
||||
<input type="number"
|
||||
name="custom_fields[{{ $field->field_key }}]"
|
||||
value="{{ old('custom_fields.' . $field->field_key) }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
{{ $field->is_required ? 'required' : '' }}>
|
||||
@break
|
||||
|
||||
@case('select')
|
||||
<select name="custom_fields[{{ $field->field_key }}]"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
{{ $field->is_required ? 'required' : '' }}>
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($field->getMeta('options', []) as $option)
|
||||
<option value="{{ $option }}" {{ old('custom_fields.' . $field->field_key) == $option ? 'selected' : '' }}>
|
||||
{{ $option }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@break
|
||||
|
||||
@case('date')
|
||||
<input type="date"
|
||||
name="custom_fields[{{ $field->field_key }}]"
|
||||
value="{{ old('custom_fields.' . $field->field_key) }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
{{ $field->is_required ? 'required' : '' }}>
|
||||
@break
|
||||
|
||||
@case('textarea')
|
||||
<textarea name="custom_fields[{{ $field->field_key }}]"
|
||||
rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
{{ $field->is_required ? 'required' : '' }}>{{ old('custom_fields.' . $field->field_key) }}</textarea>
|
||||
@break
|
||||
|
||||
@case('checkbox')
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="custom_fields[{{ $field->field_key }}]"
|
||||
value="1"
|
||||
{{ old('custom_fields.' . $field->field_key) ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm text-gray-600">{{ $field->getMeta('label', '예') }}</span>
|
||||
</label>
|
||||
@break
|
||||
|
||||
@default
|
||||
<input type="text"
|
||||
name="custom_fields[{{ $field->field_key }}]"
|
||||
value="{{ old('custom_fields.' . $field->field_key) }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@endswitch
|
||||
|
||||
@error('custom_fields.' . $field->field_key)
|
||||
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="mb-6">
|
||||
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">내용</label>
|
||||
<textarea name="content" id="content" rows="15"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('content') border-red-500 @enderror">{{ old('content') }}</textarea>
|
||||
@error('content')
|
||||
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- 파일 첨부 -->
|
||||
@if($board->allow_files)
|
||||
<div class="mb-6 border-t border-gray-200 pt-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
파일 첨부
|
||||
<span class="text-gray-400 font-normal">(최대 {{ $board->max_file_count }}개, 파일당 {{ round($board->max_file_size / 1024, 1) }}MB)</span>
|
||||
</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6">
|
||||
<input type="file" name="files[]" id="files" multiple
|
||||
class="hidden"
|
||||
accept="*/*">
|
||||
<label for="files" class="cursor-pointer block text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
<span class="text-blue-600 hover:text-blue-500">파일을 선택</span>하거나 여기로 드래그하세요
|
||||
</p>
|
||||
</label>
|
||||
<div id="file-list" class="mt-4 space-y-2"></div>
|
||||
</div>
|
||||
@error('files')
|
||||
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
@error('files.*')
|
||||
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 옵션 -->
|
||||
<div class="mb-6 flex items-center gap-6">
|
||||
@if($board->getSetting('allow_secret', true))
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="is_secret" value="1" {{ old('is_secret') ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm text-gray-700">비밀글</span>
|
||||
</label>
|
||||
@endif
|
||||
|
||||
@if(auth()->user()->hasRole(['admin', 'super-admin', 'manager']))
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="is_notice" value="1" {{ old('is_notice') ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm text-gray-700">공지사항으로 등록</span>
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="{{ route('boards.posts.index', $board) }}"
|
||||
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
|
||||
취소
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if($board->allow_files)
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const fileInput = document.getElementById('files');
|
||||
const fileList = document.getElementById('file-list');
|
||||
const dropZone = fileInput?.closest('.border-dashed');
|
||||
const maxFiles = {{ $board->max_file_count }};
|
||||
const maxSize = {{ $board->max_file_size * 1024 }}; // bytes
|
||||
|
||||
// 파일 목록 표시 함수
|
||||
function displayFiles(files) {
|
||||
fileList.innerHTML = '';
|
||||
|
||||
if (files.length > maxFiles) {
|
||||
alert(`최대 ${maxFiles}개의 파일만 첨부할 수 있습니다.`);
|
||||
fileInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
Array.from(files).forEach((file, index) => {
|
||||
if (file.size > maxSize) {
|
||||
alert(`${file.name}: 파일 크기가 너무 큽니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'flex items-center justify-between px-3 py-2 bg-blue-50 rounded-lg';
|
||||
item.innerHTML = `
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700">${file.name}</span>
|
||||
<span class="text-xs text-gray-400">(${formatFileSize(file.size)})</span>
|
||||
</div>
|
||||
`;
|
||||
fileList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 선택 이벤트
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', function() {
|
||||
displayFiles(this.files);
|
||||
});
|
||||
}
|
||||
|
||||
// 드래그앤드롭 이벤트
|
||||
if (dropZone) {
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, highlight, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, unhighlight, false);
|
||||
});
|
||||
|
||||
function highlight() {
|
||||
dropZone.classList.add('border-blue-500', 'bg-blue-50');
|
||||
dropZone.classList.remove('border-gray-300');
|
||||
}
|
||||
|
||||
function unhighlight() {
|
||||
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
|
||||
dropZone.classList.add('border-gray-300');
|
||||
}
|
||||
|
||||
dropZone.addEventListener('drop', handleDrop, false);
|
||||
|
||||
function handleDrop(e) {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
|
||||
// DataTransfer를 사용하여 file input에 파일 설정
|
||||
const dataTransfer = new DataTransfer();
|
||||
Array.from(files).forEach(file => dataTransfer.items.add(file));
|
||||
fileInput.files = dataTransfer.files;
|
||||
|
||||
displayFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(2) + ' GB';
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(2) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||||
return bytes + ' bytes';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@endif
|
||||
@endsection
|
||||
374
resources/views/posts/edit.blade.php
Normal file
374
resources/views/posts/edit.blade.php
Normal file
@@ -0,0 +1,374 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '게시글 수정')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">게시글 수정</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $board->name }}</p>
|
||||
</div>
|
||||
<a href="{{ route('boards.posts.show', [$board, $post]) }}" class="text-gray-600 hover:text-gray-900">
|
||||
← 돌아가기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 수정 폼 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<form action="{{ route('boards.posts.update', [$board, $post]) }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<!-- 제목 -->
|
||||
<div class="mb-6">
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
제목 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="title" id="title" value="{{ old('title', $post->title) }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('title') border-red-500 @enderror"
|
||||
required>
|
||||
@error('title')
|
||||
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- 커스텀 필드들 -->
|
||||
@if($fields->isNotEmpty())
|
||||
<div class="mb-6 border-t border-gray-200 pt-6">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-4">추가 정보</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
@foreach($fields as $field)
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{{ $field->name }}
|
||||
@if($field->is_required)
|
||||
<span class="text-red-500">*</span>
|
||||
@endif
|
||||
</label>
|
||||
|
||||
@php
|
||||
$fieldValue = old('custom_fields.' . $field->field_key, $customFieldValues[$field->field_key] ?? '');
|
||||
@endphp
|
||||
|
||||
@switch($field->field_type)
|
||||
@case('text')
|
||||
<input type="text"
|
||||
name="custom_fields[{{ $field->field_key }}]"
|
||||
value="{{ $fieldValue }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
{{ $field->is_required ? 'required' : '' }}>
|
||||
@break
|
||||
|
||||
@case('number')
|
||||
<input type="number"
|
||||
name="custom_fields[{{ $field->field_key }}]"
|
||||
value="{{ $fieldValue }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
{{ $field->is_required ? 'required' : '' }}>
|
||||
@break
|
||||
|
||||
@case('select')
|
||||
<select name="custom_fields[{{ $field->field_key }}]"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
{{ $field->is_required ? 'required' : '' }}>
|
||||
<option value="">선택하세요</option>
|
||||
@foreach($field->getMeta('options', []) as $option)
|
||||
<option value="{{ $option }}" {{ $fieldValue == $option ? 'selected' : '' }}>
|
||||
{{ $option }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@break
|
||||
|
||||
@case('date')
|
||||
<input type="date"
|
||||
name="custom_fields[{{ $field->field_key }}]"
|
||||
value="{{ $fieldValue }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
{{ $field->is_required ? 'required' : '' }}>
|
||||
@break
|
||||
|
||||
@case('textarea')
|
||||
<textarea name="custom_fields[{{ $field->field_key }}]"
|
||||
rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
{{ $field->is_required ? 'required' : '' }}>{{ $fieldValue }}</textarea>
|
||||
@break
|
||||
|
||||
@case('checkbox')
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
name="custom_fields[{{ $field->field_key }}]"
|
||||
value="1"
|
||||
{{ $fieldValue ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm text-gray-600">{{ $field->getMeta('label', '예') }}</span>
|
||||
</label>
|
||||
@break
|
||||
|
||||
@default
|
||||
<input type="text"
|
||||
name="custom_fields[{{ $field->field_key }}]"
|
||||
value="{{ $fieldValue }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@endswitch
|
||||
|
||||
@error('custom_fields.' . $field->field_key)
|
||||
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="mb-6">
|
||||
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">내용</label>
|
||||
<textarea name="content" id="content" rows="15"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('content') border-red-500 @enderror">{{ old('content', $post->content) }}</textarea>
|
||||
@error('content')
|
||||
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<!-- 기존 첨부파일 -->
|
||||
@if($board->allow_files && $post->files->isNotEmpty())
|
||||
<div class="mb-6 border-t border-gray-200 pt-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
기존 첨부파일 ({{ $post->files->count() }}개)
|
||||
</label>
|
||||
<div id="existing-files" class="space-y-2">
|
||||
@foreach($post->files as $file)
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 rounded-lg" data-file-id="{{ $file->id }}">
|
||||
<div class="flex items-center gap-3">
|
||||
@if($file->isImage())
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
@endif
|
||||
<div>
|
||||
<span class="text-sm text-gray-900">{{ $file->display_name ?? $file->original_name }}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">({{ $file->getFormattedSize() }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
onclick="deleteFile({{ $file->id }})"
|
||||
class="px-3 py-1 text-sm text-red-600 hover:text-red-800 border border-red-300 rounded hover:bg-red-50 transition">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 새 파일 첨부 -->
|
||||
@if($board->allow_files)
|
||||
<div class="mb-6 border-t border-gray-200 pt-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
파일 추가
|
||||
<span class="text-gray-400 font-normal">(최대 {{ $board->max_file_count }}개, 파일당 {{ round($board->max_file_size / 1024, 1) }}MB)</span>
|
||||
</label>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6">
|
||||
<input type="file" name="files[]" id="files" multiple
|
||||
class="hidden"
|
||||
accept="*/*">
|
||||
<label for="files" class="cursor-pointer block text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
<span class="text-blue-600 hover:text-blue-500">파일을 선택</span>하거나 여기로 드래그하세요
|
||||
</p>
|
||||
</label>
|
||||
<div id="file-list" class="mt-4 space-y-2"></div>
|
||||
</div>
|
||||
@error('files')
|
||||
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
@error('files.*')
|
||||
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 옵션 -->
|
||||
<div class="mb-6 flex items-center gap-6">
|
||||
@if($board->getSetting('allow_secret', true))
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="is_secret" value="1" {{ old('is_secret', $post->is_secret) ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm text-gray-700">비밀글</span>
|
||||
</label>
|
||||
@endif
|
||||
|
||||
@if(auth()->user()->hasRole(['admin', 'super-admin', 'manager']))
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="is_notice" value="1" {{ old('is_notice', $post->is_notice) ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm text-gray-700">공지사항으로 등록</span>
|
||||
</label>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="{{ route('boards.posts.show', [$board, $post]) }}"
|
||||
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
|
||||
취소
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if($board->allow_files)
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const fileInput = document.getElementById('files');
|
||||
const fileList = document.getElementById('file-list');
|
||||
const dropZone = fileInput?.closest('.border-dashed');
|
||||
const existingCount = {{ $post->files->count() }};
|
||||
const maxFiles = {{ $board->max_file_count }};
|
||||
const maxSize = {{ $board->max_file_size * 1024 }}; // bytes
|
||||
|
||||
// 파일 목록 표시 함수
|
||||
function displayFiles(files) {
|
||||
fileList.innerHTML = '';
|
||||
const currentExisting = document.querySelectorAll('#existing-files > div').length;
|
||||
|
||||
if (files.length + currentExisting > maxFiles) {
|
||||
alert(`최대 ${maxFiles}개의 파일만 첨부할 수 있습니다. (기존 ${currentExisting}개 포함)`);
|
||||
fileInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
Array.from(files).forEach((file, index) => {
|
||||
if (file.size > maxSize) {
|
||||
alert(`${file.name}: 파일 크기가 너무 큽니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'flex items-center justify-between px-3 py-2 bg-blue-50 rounded-lg';
|
||||
item.innerHTML = `
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700">${file.name}</span>
|
||||
<span class="text-xs text-gray-400">(${formatFileSize(file.size)})</span>
|
||||
<span class="text-xs text-blue-500">(새 파일)</span>
|
||||
</div>
|
||||
`;
|
||||
fileList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 선택 이벤트
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', function() {
|
||||
displayFiles(this.files);
|
||||
});
|
||||
}
|
||||
|
||||
// 드래그앤드롭 이벤트
|
||||
if (dropZone) {
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, highlight, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, unhighlight, false);
|
||||
});
|
||||
|
||||
function highlight() {
|
||||
dropZone.classList.add('border-blue-500', 'bg-blue-50');
|
||||
dropZone.classList.remove('border-gray-300');
|
||||
}
|
||||
|
||||
function unhighlight() {
|
||||
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
|
||||
dropZone.classList.add('border-gray-300');
|
||||
}
|
||||
|
||||
dropZone.addEventListener('drop', handleDrop, false);
|
||||
|
||||
function handleDrop(e) {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
|
||||
// DataTransfer를 사용하여 file input에 파일 설정
|
||||
const dataTransfer = new DataTransfer();
|
||||
Array.from(files).forEach(file => dataTransfer.items.add(file));
|
||||
fileInput.files = dataTransfer.files;
|
||||
|
||||
displayFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(2) + ' GB';
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(2) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||||
return bytes + ' bytes';
|
||||
}
|
||||
});
|
||||
|
||||
function deleteFile(fileId) {
|
||||
if (!confirm('이 파일을 삭제하시겠습니까?')) return;
|
||||
|
||||
const url = '{{ route("boards.posts.files.delete", [$board, $post, ":fileId"]) }}'.replace(':fileId', fileId);
|
||||
|
||||
fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const fileElement = document.querySelector(`[data-file-id="${fileId}"]`);
|
||||
if (fileElement) {
|
||||
fileElement.remove();
|
||||
}
|
||||
// 남은 파일이 없으면 섹션 숨김
|
||||
const existingFiles = document.getElementById('existing-files');
|
||||
if (existingFiles && existingFiles.children.length === 0) {
|
||||
existingFiles.closest('.mb-6').remove();
|
||||
}
|
||||
} else {
|
||||
alert(data.message || '파일 삭제에 실패했습니다.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('파일 삭제 중 오류가 발생했습니다.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@endif
|
||||
@endsection
|
||||
154
resources/views/posts/index.blade.php
Normal file
154
resources/views/posts/index.blade.php
Normal file
@@ -0,0 +1,154 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $board->name)
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">{{ $board->name }}</h1>
|
||||
@if($board->description)
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $board->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('boards.posts.create', $board) }}"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
글쓰기
|
||||
</a>
|
||||
<a href="{{ route('boards.index') }}"
|
||||
class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm font-medium transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
게시판 목록
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">전체 게시글</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ number_format($stats['total']) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">공지사항</div>
|
||||
<div class="text-2xl font-bold text-blue-600">{{ number_format($stats['notices']) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">오늘 작성</div>
|
||||
<div class="text-2xl font-bold text-green-600">{{ number_format($stats['today']) }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">게시 중</div>
|
||||
<div class="text-2xl font-bold text-gray-600">{{ number_format($stats['published']) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<form action="{{ route('boards.posts.index', $board) }}" method="GET" class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<input type="text" name="search" value="{{ $filters['search'] ?? '' }}"
|
||||
placeholder="제목, 내용 검색..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
|
||||
검색
|
||||
</button>
|
||||
@if(!empty($filters['search']))
|
||||
<a href="{{ route('boards.posts.index', $board) }}" class="px-4 py-2 text-gray-500 hover:text-gray-700">
|
||||
초기화
|
||||
</a>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 게시글 목록 -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">번호</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">제목</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-32">작성자</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-32">작성일</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20">조회</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($posts as $post)
|
||||
<tr class="hover:bg-gray-50 {{ $post->is_notice ? 'bg-blue-50' : '' }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
@if($post->is_notice)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
공지
|
||||
</span>
|
||||
@else
|
||||
{{ $post->id }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<a href="{{ route('boards.posts.show', [$board, $post]) }}"
|
||||
class="text-gray-900 hover:text-blue-600 font-medium">
|
||||
@if($post->is_secret)
|
||||
<svg class="w-4 h-4 inline-block text-gray-400 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
@endif
|
||||
{{ $post->title }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $post->author?->name ?? '알 수 없음' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $post->created_at->format('Y-m-d') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ number_format($post->views) }}
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-12 text-center text-gray-500">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p>게시글이 없습니다.</p>
|
||||
<a href="{{ route('boards.posts.create', $board) }}" class="mt-2 inline-block text-blue-600 hover:text-blue-800">
|
||||
첫 게시글 작성하기
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
@if($posts->hasPages())
|
||||
<div class="px-6 py-4 border-t border-gray-200">
|
||||
{{ $posts->withQueryString()->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
@if(session('success'))
|
||||
<div class="fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg" id="toast">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
<script>setTimeout(() => document.getElementById('toast')?.remove(), 3000);</script>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg" id="toast-error">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
<script>setTimeout(() => document.getElementById('toast-error')?.remove(), 3000);</script>
|
||||
@endif
|
||||
@endsection
|
||||
165
resources/views/posts/show.blade.php
Normal file
165
resources/views/posts/show.blade.php
Normal file
@@ -0,0 +1,165 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $post->title)
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">{{ $board->name }}</p>
|
||||
<h1 class="text-2xl font-bold text-gray-800 mt-1">
|
||||
@if($post->is_notice)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-sm font-medium bg-blue-100 text-blue-800 mr-2">
|
||||
공지
|
||||
</span>
|
||||
@endif
|
||||
@if($post->is_secret)
|
||||
<svg class="w-5 h-5 inline-block text-gray-400 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
@endif
|
||||
{{ $post->title }}
|
||||
</h1>
|
||||
</div>
|
||||
<a href="{{ route('boards.posts.index', $board) }}" class="text-gray-600 hover:text-gray-900">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 게시글 정보 -->
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<!-- 메타 정보 -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||
<div class="flex items-center gap-6">
|
||||
<span>
|
||||
<span class="font-medium text-gray-700">작성자:</span>
|
||||
{{ $post->author?->name ?? '알 수 없음' }}
|
||||
</span>
|
||||
<span>
|
||||
<span class="font-medium text-gray-700">작성일:</span>
|
||||
{{ $post->created_at->format('Y-m-d H:i') }}
|
||||
</span>
|
||||
<span>
|
||||
<span class="font-medium text-gray-700">조회:</span>
|
||||
{{ number_format($post->views) }}
|
||||
</span>
|
||||
</div>
|
||||
@if($post->user_id === auth()->id() || auth()->user()->hasRole(['admin', 'super-admin']))
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('boards.posts.edit', [$board, $post]) }}"
|
||||
class="px-3 py-1 text-sm text-blue-600 hover:text-blue-800 border border-blue-300 rounded hover:bg-blue-50 transition">
|
||||
수정
|
||||
</a>
|
||||
<form action="{{ route('boards.posts.destroy', [$board, $post]) }}" method="POST"
|
||||
onsubmit="return confirm('정말 삭제하시겠습니까?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit"
|
||||
class="px-3 py-1 text-sm text-red-600 hover:text-red-800 border border-red-300 rounded hover:bg-red-50 transition">
|
||||
삭제
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 커스텀 필드 값 -->
|
||||
@if(!empty($customFieldValues))
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
@foreach($board->fields as $field)
|
||||
@if(isset($customFieldValues[$field->field_key]))
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">{{ $field->name }}:</span>
|
||||
<span class="text-gray-600 ml-1">{{ $customFieldValues[$field->field_key] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 본문 -->
|
||||
<div class="px-6 py-8">
|
||||
<div class="prose max-w-none">
|
||||
{!! nl2br(e($post->content)) !!}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 첨부파일 -->
|
||||
@if($post->files->isNotEmpty())
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||
첨부파일 ({{ $post->files->count() }}개)
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
@foreach($post->files as $file)
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-white rounded-lg border border-gray-200">
|
||||
<div class="flex items-center gap-3">
|
||||
@if($file->isImage())
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
@else
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
@endif
|
||||
<div>
|
||||
<span class="text-sm text-gray-900">{{ $file->display_name ?? $file->original_name }}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">({{ $file->getFormattedSize() }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ route('boards.posts.files.download', [$board, $post, $file->id]) }}"
|
||||
class="px-3 py-1 text-sm text-blue-600 hover:text-blue-800 border border-blue-300 rounded hover:bg-blue-50 transition">
|
||||
다운로드
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- 이전/다음 글 네비게이션 -->
|
||||
<div class="mt-6 bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="divide-y divide-gray-200">
|
||||
@if($adjacent['prev'])
|
||||
<a href="{{ route('boards.posts.show', [$board, $adjacent['prev']->id]) }}"
|
||||
class="block px-6 py-4 hover:bg-gray-50 transition">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-500 w-20">이전글</span>
|
||||
<span class="text-gray-900">{{ $adjacent['prev']->title }}</span>
|
||||
</div>
|
||||
</a>
|
||||
@endif
|
||||
@if($adjacent['next'])
|
||||
<a href="{{ route('boards.posts.show', [$board, $adjacent['next']->id]) }}"
|
||||
class="block px-6 py-4 hover:bg-gray-50 transition">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-500 w-20">다음글</span>
|
||||
<span class="text-gray-900">{{ $adjacent['next']->title }}</span>
|
||||
</div>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 목록 버튼 -->
|
||||
<div class="mt-6 flex justify-center">
|
||||
<a href="{{ route('boards.posts.index', $board) }}"
|
||||
class="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
|
||||
목록
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
@if(session('success'))
|
||||
<div class="fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg" id="toast">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
<script>setTimeout(() => document.getElementById('toast')?.remove(), 3000);</script>
|
||||
@endif
|
||||
@endsection
|
||||
@@ -106,6 +106,13 @@
|
||||
Route::get('/{post}/edit', [PostController::class, 'edit'])->name('edit');
|
||||
Route::put('/{post}', [PostController::class, 'update'])->name('update');
|
||||
Route::delete('/{post}', [PostController::class, 'destroy'])->name('destroy');
|
||||
|
||||
// 파일 관리
|
||||
Route::prefix('{post}/files')->name('files.')->group(function () {
|
||||
Route::get('/{fileId}/download', [PostController::class, 'downloadFile'])->name('download');
|
||||
Route::post('/', [PostController::class, 'uploadFiles'])->name('upload');
|
||||
Route::delete('/{fileId}', [PostController::class, 'deleteFile'])->name('delete');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user