feat: [claude-code] Claude Code 뉴스 페이지 추가
- GitHub Releases API 연동 서비스 (1시간 캐싱) - 뉴스 컨트롤러 + Blade 뷰 (릴리즈 카드 목록) - /claude-code/news 라우트 그룹 등록
This commit is contained in:
42
app/Http/Controllers/ClaudeCode/NewsController.php
Normal file
42
app/Http/Controllers/ClaudeCode/NewsController.php
Normal 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', '캐시가 새로고침되었습니다.');
|
||||
}
|
||||
}
|
||||
74
app/Services/ClaudeCodeNewsService.php
Normal file
74
app/Services/ClaudeCodeNewsService.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
166
resources/views/claude-code/news/index.blade.php
Normal file
166
resources/views/claude-code/news/index.blade.php
Normal 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
|
||||
@@ -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 마이그레이션)
|
||||
|
||||
Reference in New Issue
Block a user