feat: [claude-code] Claude Code 뉴스 페이지 추가

- GitHub Releases API 연동 서비스 (1시간 캐싱)
- 뉴스 컨트롤러 + Blade 뷰 (릴리즈 카드 목록)
- /claude-code/news 라우트 그룹 등록
This commit is contained in:
김보곤
2026-03-02 10:41:50 +09:00
parent 511fe69593
commit ab042cb132
4 changed files with 295 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\ClaudeCode;
use App\Http\Controllers\Controller;
use App\Services\ClaudeCodeNewsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class NewsController extends Controller
{
public function __construct(
private ClaudeCodeNewsService $newsService
) {}
/**
* Claude Code 뉴스 (GitHub Releases) 목록
*/
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('claude-code.news.index'));
}
$releases = $this->newsService->getReleases();
return view('claude-code.news.index', compact('releases'));
}
/**
* 캐시 새로고침
*/
public function refreshCache(): RedirectResponse
{
$this->newsService->clearCache();
return redirect()->route('claude-code.news.index')
->with('success', '캐시가 새로고침되었습니다.');
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class ClaudeCodeNewsService
{
private const CACHE_KEY = 'claude_code_releases';
private const CACHE_TTL = 3600; // 1시간
private const API_URL = 'https://api.github.com/repos/anthropics/claude-code/releases';
/**
* GitHub Releases 목록 조회 (캐싱)
*/
public function getReleases(int $perPage = 20): array
{
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () use ($perPage) {
return $this->fetchFromGitHub($perPage);
});
}
/**
* 캐시 클리어
*/
public function clearCache(): void
{
Cache::forget(self::CACHE_KEY);
}
/**
* GitHub API에서 릴리즈 정보 가져오기
*/
private function fetchFromGitHub(int $perPage): array
{
try {
$response = Http::withHeaders([
'Accept' => 'application/vnd.github.v3+json',
'User-Agent' => 'SAM-MNG-App',
])->timeout(10)->get(self::API_URL, [
'per_page' => $perPage,
]);
if (! $response->successful()) {
return [];
}
return collect($response->json())
->map(function ($release) {
return [
'id' => $release['id'],
'tag_name' => $release['tag_name'],
'name' => $release['name'] ?? $release['tag_name'],
'body_html' => Str::markdown($release['body'] ?? ''),
'published_at' => $release['published_at'],
'author' => $release['author']['login'] ?? 'unknown',
'author_avatar' => $release['author']['avatar_url'] ?? '',
'html_url' => $release['html_url'],
'prerelease' => $release['prerelease'] ?? false,
'draft' => $release['draft'] ?? false,
];
})
->toArray();
} catch (\Exception $e) {
report($e);
return [];
}
}
}

View File

@@ -0,0 +1,166 @@
@extends('layouts.app')
@section('title', 'Claude Code 뉴스')
@push('styles')
<style>
/* Markdown 렌더링 스타일 */
.release-body h1 { font-size: 1.25rem; font-weight: 700; margin: 1rem 0 0.5rem; color: #1f2937; }
.release-body h2 { font-size: 1.125rem; font-weight: 600; margin: 1rem 0 0.5rem; color: #374151; }
.release-body h3 { font-size: 1rem; font-weight: 600; margin: 0.75rem 0 0.375rem; color: #4b5563; }
.release-body p { margin: 0.5rem 0; line-height: 1.625; color: #374151; }
.release-body ul { list-style-type: disc; padding-left: 1.5rem; margin: 0.5rem 0; }
.release-body ol { list-style-type: decimal; padding-left: 1.5rem; margin: 0.5rem 0; }
.release-body li { margin: 0.25rem 0; line-height: 1.5; color: #374151; }
.release-body code { background: #f3f4f6; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.875rem; color: #dc2626; }
.release-body pre { background: #1f2937; color: #e5e7eb; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 0.75rem 0; }
.release-body pre code { background: transparent; color: inherit; padding: 0; }
.release-body a { color: #2563eb; text-decoration: underline; }
.release-body a:hover { color: #1d4ed8; }
.release-body blockquote { border-left: 3px solid #d1d5db; padding-left: 1rem; margin: 0.75rem 0; color: #6b7280; }
.release-body hr { border: none; border-top: 1px solid #e5e7eb; margin: 1rem 0; }
.release-body table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
.release-body th, .release-body td { border: 1px solid #d1d5db; padding: 0.5rem 0.75rem; text-align: left; }
.release-body th { background: #f9fafb; font-weight: 600; }
</style>
@endpush
@section('content')
<div class="space-y-6">
{{-- 페이지 헤더 --}}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Claude Code 뉴스</h1>
<p class="mt-1 text-sm text-gray-500">GitHub Releases에서 최신 업데이트를 확인합니다.</p>
</div>
<div class="flex items-center gap-2">
<form action="{{ route('claude-code.news.refresh-cache') }}" method="POST">
@csrf
<button type="submit" class="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
새로고침
</button>
</form>
<a href="https://github.com/anthropics/claude-code/releases" target="_blank" rel="noopener"
class="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-gray-800 rounded-lg hover:bg-gray-700">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub
</a>
</div>
</div>
{{-- 성공 메시지 --}}
@if(session('success'))
<div class="p-3 text-sm text-green-700 bg-green-50 border border-green-200 rounded-lg">
{{ session('success') }}
</div>
@endif
@if(count($releases) > 0)
{{-- 요약 카드 --}}
<div class="flex gap-4" style="flex-wrap: wrap;">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4" style="flex: 1 1 200px; max-width: 300px;">
<div class="text-sm text-gray-500">최신 버전</div>
<div class="text-xl font-bold text-indigo-600 mt-1">{{ $releases[0]['tag_name'] }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4" style="flex: 1 1 200px; max-width: 300px;">
<div class="text-sm text-gray-500"> 릴리즈</div>
<div class="text-xl font-bold text-gray-900 mt-1">{{ count($releases) }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4" style="flex: 1 1 200px; max-width: 300px;">
<div class="text-sm text-gray-500">최근 릴리즈</div>
<div class="text-xl font-bold text-gray-900 mt-1">
{{ \Carbon\Carbon::parse($releases[0]['published_at'])->format('Y-m-d') }}
</div>
</div>
</div>
{{-- 릴리즈 목록 --}}
<div class="space-y-4">
@foreach($releases as $index => $release)
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
{{-- 헤더 --}}
<div class="flex items-center justify-between px-5 py-3 bg-gray-50 border-b border-gray-200 cursor-pointer"
onclick="toggleRelease({{ $index }})">
<div class="flex items-center gap-3">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold
{{ $release['prerelease'] ? 'bg-yellow-100 text-yellow-800' : 'bg-indigo-100 text-indigo-800' }}">
{{ $release['tag_name'] }}
</span>
<span class="text-sm font-medium text-gray-700">{{ $release['name'] }}</span>
@if($release['prerelease'])
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-50 text-yellow-700 border border-yellow-200">
Pre-release
</span>
@endif
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 text-xs text-gray-500">
@if($release['author_avatar'])
<img src="{{ $release['author_avatar'] }}" alt="" class="w-4 h-4 rounded-full">
@endif
<span>{{ $release['author'] }}</span>
</div>
<span class="text-xs text-gray-400">
{{ \Carbon\Carbon::parse($release['published_at'])->format('Y-m-d H:i') }}
</span>
<a href="{{ $release['html_url'] }}" target="_blank" rel="noopener"
class="text-gray-400 hover:text-gray-600" onclick="event.stopPropagation()">
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
<svg class="w-4 h-4 text-gray-400 transition-transform" id="chevron-{{ $index }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</div>
</div>
{{-- 본문 (첫번째만 열림) --}}
<div id="release-body-{{ $index }}" class="{{ $index === 0 ? '' : 'hidden' }}">
<div class="px-5 py-4 release-body text-sm">
{!! $release['body_html'] !!}
</div>
</div>
</div>
@endforeach
</div>
@else
{{-- 상태 --}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">릴리즈 정보를 불러올 없습니다</h3>
<p class="mt-2 text-sm text-gray-500">GitHub API에 접속할 없거나 릴리즈가 없습니다.</p>
<div class="mt-4">
<form action="{{ route('claude-code.news.refresh-cache') }}" method="POST" class="inline">
@csrf
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700">
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
다시 시도
</button>
</form>
</div>
</div>
@endif
</div>
<script>
function toggleRelease(index) {
const body = document.getElementById('release-body-' + index);
const chevron = document.getElementById('chevron-' + index);
if (body.classList.contains('hidden')) {
body.classList.remove('hidden');
chevron.style.transform = 'rotate(180deg)';
} else {
body.classList.add('hidden');
chevron.style.transform = '';
}
}
// 첫번째 항목 chevron 초기 상태
document.addEventListener('DOMContentLoaded', function() {
const firstChevron = document.getElementById('chevron-0');
if (firstChevron) {
firstChevron.style.transform = 'rotate(180deg)';
}
});
</script>
@endsection

View File

@@ -32,6 +32,7 @@
use App\Http\Controllers\Juil\ConstructionSitePhotoController;
use App\Http\Controllers\Juil\MeetingMinuteController;
use App\Http\Controllers\Juil\PlanningController;
use App\Http\Controllers\ClaudeCode\NewsController as ClaudeCodeNewsController;
use App\Http\Controllers\Lab\StrategyController;
use App\Http\Controllers\MenuController;
use App\Http\Controllers\MenuSyncController;
@@ -712,6 +713,18 @@
return redirect()->route('dashboard');
});
/*
|--------------------------------------------------------------------------
| Claude Code Routes
|--------------------------------------------------------------------------
*/
Route::prefix('claude-code')->name('claude-code.')->group(function () {
Route::prefix('news')->name('news.')->group(function () {
Route::get('/', [ClaudeCodeNewsController::class, 'index'])->name('index');
Route::post('/refresh', [ClaudeCodeNewsController::class, 'refreshCache'])->name('refresh-cache');
});
});
/*
|--------------------------------------------------------------------------
| R&D Labs Routes (5130 마이그레이션)