From 027c9f77847fab1121755c33697f79887903bb23 Mon Sep 17 00:00:00 2001 From: "nikita.hohlov" Date: Fri, 4 Sep 2026 08:16:04 -0300 Subject: [PATCH] feat: web inbox per Sekai design handoff Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw --- core/app/Http/Controllers/InboxController.php | 103 ++++++++ core/public/inbox.js | 92 ++++++++ core/resources/views/inbox.blade.php | 196 +++++++++++++++ core/resources/views/welcome.blade.php | 223 ------------------ core/routes/web.php | 2 + core/tests/Feature/InboxPageTest.php | 91 +++++++ 6 files changed, 484 insertions(+), 223 deletions(-) create mode 100644 core/app/Http/Controllers/InboxController.php create mode 100644 core/public/inbox.js create mode 100644 core/resources/views/inbox.blade.php delete mode 100644 core/resources/views/welcome.blade.php create mode 100644 core/tests/Feature/InboxPageTest.php diff --git a/core/app/Http/Controllers/InboxController.php b/core/app/Http/Controllers/InboxController.php new file mode 100644 index 0000000..7b4e073 --- /dev/null +++ b/core/app/Http/Controllers/InboxController.php @@ -0,0 +1,103 @@ +attributes->get('user'); + $today = $user->now()->startOfDay(); + + $live = $user->events()->with('source') + ->whereIn('state', EventState::nonTerminalValues()) + ->get() + ->map(fn (Event $e) => $this->card($e, $user, $today)) + ->sortBy('sort')->values(); + + $groups = []; + foreach (['today' => ['今日', 'СЕГОДНЯ'], 'overdue' => ['遅延', 'ПРОСРОЧЕНО'], 'preparing' => ['近日', 'ГРЯДЁТ'], 'scheduled' => ['予定', 'ЗАПЛАНИРОВАНО']] as $state => [$jp, $ru]) { + $groups[] = ['state' => $state, 'jp' => $jp, 'ru' => $ru, 'events' => $live->where('state', $state)->values()->all()]; + } + + $archive = $user->events()->with('source') + ->whereIn('state', [EventState::Done->value, EventState::Expired->value]) + ->orderByDesc('updated_at')->limit(100)->get() + ->map(fn (Event $e) => [ + 'title' => $e->payload['title'], + 'source' => self::sourceLabel($e->source->name), + 'due' => Presenter::present($e)['due_at'], + 'closed_as' => $e->state === EventState::Expired ? 'Истекло' : ($e->payload['done_label'] ?? 'Сделано'), + ])->values()->all(); + + $profile = ProfileController::present($user); + + return view('inbox', [ + 'profile' => $profile, + 'title' => $profile['badge'] > 0 ? "({$profile['badge']}) Hado" : 'Hado', + 'groups' => $groups, + 'archive' => $archive, + 'channels' => $user->channels()->orderBy('id')->get()->map(ChannelsController::present(...))->values()->all(), + 'timezones' => \DateTimeZone::listIdentifiers(), + ]); + } + + /** @return array */ + private function card(Event $e, User $user, CarbonImmutable $today): array + { + $due = $this->due->resolve($e, $user); + $wholeDay = $due->moment->eq($due->day->addDay()); + $diff = (int) round($today->diffInDays($due->day)); + + return [ + 'id' => $e->id, + 'state' => $e->state->value, + 'title' => $e->payload['title'], + 'subtitle' => $e->payload['subtitle'] ?? null, + 'link' => $e->payload['deep_link'] ?? null, + 'done_label' => $e->payload['done_label'] ?? 'Сделано', + 'can_remind' => $e->state !== EventState::Scheduled, + 'source' => self::sourceLabel($e->source->name), + 'due' => Presenter::present($e)['due_at'], + 'due_month' => self::MONTHS[(int) $due->day->format('n') - 1], + 'due_day' => $due->day->format('d'), + 'due_time' => $wholeDay ? null : $due->moment->format('H:i'), + 'rel' => self::relative($diff), + 'sort' => $due->moment->getTimestamp(), + ]; + } + + public static function sourceLabel(string $name): string + { + return $name === 'manual' ? 'вручную' : $name; + } + + public static function relative(int $diff): string + { + $days = fn (int $n) => $n % 10 === 1 && $n % 100 !== 11 ? 'день' : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14) ? 'дня' : 'дней'); + + return match (true) { + $diff === 0 => 'сегодня', + $diff === 1 => 'завтра', + $diff > 1 => "через {$diff} ".$days($diff), + $diff === -1 => 'вчера', + default => abs($diff).' '.$days(abs($diff)).' назад', + }; + } +} diff --git a/core/public/inbox.js b/core/public/inbox.js new file mode 100644 index 0000000..883a8f0 --- /dev/null +++ b/core/public/inbox.js @@ -0,0 +1,92 @@ +(() => { + const csrf = document.querySelector('meta[name=csrf-token]').content; + const call = (method, url, body) => fetch(url, { + method, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf }, + body: body ? JSON.stringify(body) : undefined, + }); + const showErr = async (el, r) => { + const d = await r.json().catch(() => ({})); + el.textContent = d.message || Object.values(d.errors || {}).flat().join(' ') || 'Ошибка'; + el.hidden = false; + }; + + // Звёзды: 90 по небу, 170 в полосе (дуга −14°) + const star = (color, size, bright, x, y, dur, delay) => { + const s = document.createElement('div'); + s.style.cssText = `position:absolute;border-radius:50%;left:${x}%;top:${y}%;width:${size}px;height:${size}px;background:${color};${bright ? `box-shadow:0 0 6px ${color};` : ''}animation:skTwinkle ${dur}s ease-in-out ${delay}s infinite`; + return s; + }; + const sky = document.getElementById('stars'), band = document.getElementById('band'); + for (let i = 0; i < 90; i++) sky.appendChild(star(Math.random() < .2 ? '#8FD6DC' : '#F2ECE0', Math.random() < .85 ? 1.5 : 2.5, false, (Math.random() * 100).toFixed(2), (Math.random() * 100).toFixed(2), (3 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1))); + for (let j = 0; j < 170; j++) { + const bright = Math.random() < .08, r = Math.random(); + const color = r < .68 ? '#F2ECE0' : (r < .88 ? '#F7CEDC' : '#8FD6DC'); + const g = (Math.random() + Math.random() + Math.random()) / 3, x = Math.random() * 100; + const arc = 28 * Math.pow((x - 50) / 50, 2) + Math.max(0, 35 - x) * .35; + band.appendChild(star(color, bright ? 2.2 : (0.8 + Math.random() * 1.1).toFixed(1), bright, x.toFixed(2), (6 + g * 55 + arc).toFixed(2), (2.5 + Math.random() * 5).toFixed(1), (-Math.random() * 8).toFixed(1))); + } + + // Панели архив/настройки + document.querySelectorAll('.panel .head').forEach(h => h.addEventListener('click', () => { + const body = h.nextElementSibling; body.hidden = !body.hidden; + h.querySelector('.chev').textContent = body.hidden ? '▼' : '▲'; + })); + + // Форма добавления + const addform = document.getElementById('addform'); + const toggleAdd = () => { addform.hidden = !addform.hidden; document.getElementById('add-err').hidden = true; }; + document.getElementById('toggle-add').addEventListener('click', toggleAdd); + document.getElementById('cancel-add').addEventListener('click', toggleAdd); + document.getElementById('add').addEventListener('submit', async e => { + e.preventDefault(); + const f = new FormData(e.target); + const r = await call('POST', '/me/events', { title: f.get('title'), subtitle: f.get('subtitle') || null, due_date: f.get('due_date'), due_time: f.get('due_time') || null }); + if (r.ok) location.reload(); else showErr(document.getElementById('add-err'), r); + }); + + // «Помню» / «Сделано» + document.querySelectorAll('.card button[data-action]').forEach(btn => btn.addEventListener('click', async () => { + const card = btn.closest('.card'); + card.querySelectorAll('button').forEach(b => b.disabled = true); + const r = await call('POST', `/me/events/${card.dataset.id}/${btn.dataset.action}`); + if (r.ok) location.reload(); else card.querySelectorAll('button').forEach(b => b.disabled = false); + })); + + // Настройки + document.getElementById('profile').addEventListener('submit', async e => { + e.preventDefault(); + const f = new FormData(e.target); + const r = await call('PATCH', '/me', { tz: f.get('tz'), quiet_start: f.get('quiet_start'), quiet_end: f.get('quiet_end') }); + if (r.ok) location.reload(); else showErr(document.getElementById('profile-err'), r); + }); + document.getElementById('webhook').addEventListener('submit', async e => { + e.preventDefault(); + const f = new FormData(e.target); + const r = await call('POST', '/me/channels', { type: 'webhook', config: { deliver_url: f.get('deliver_url'), presence_url: f.get('presence_url') } }); + if (r.ok) location.reload(); else showErr(document.getElementById('webhook-err'), r); + }); + document.querySelectorAll('[data-delete-channel]').forEach(btn => btn.addEventListener('click', async () => { + const r = await call('DELETE', `/me/channels/${btn.dataset.deleteChannel}`); + if (r.ok) location.reload(); + })); + document.getElementById('tg-connect').addEventListener('click', async e => { + const r = await call('POST', '/me/channels/telegram/link'); + if (!r.ok) return; + const d = await r.json(); + document.getElementById('tg-code').textContent = `/start ${d.code}`; + document.getElementById('tg-link').href = d.bot_url; + e.target.hidden = true; document.getElementById('tg-box').hidden = false; + }); + + // Присутствие: heartbeat только при видимой вкладке и активности за 3 минуты. + let lastActivity = Date.now(); + ['pointerdown', 'pointermove', 'keydown', 'scroll'].forEach(ev => addEventListener(ev, () => { lastActivity = Date.now(); }, { passive: true })); + const heartbeat = () => { + if (document.visibilityState !== 'visible') return; + if (Date.now() - lastActivity > 3 * 60 * 1000) return; + call('POST', '/me/heartbeat').catch(() => {}); + }; + heartbeat(); + setInterval(heartbeat, 30 * 1000); + document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { lastActivity = Date.now(); heartbeat(); } }); +})(); diff --git a/core/resources/views/inbox.blade.php b/core/resources/views/inbox.blade.php new file mode 100644 index 0000000..3b20f20 --- /dev/null +++ b/core/resources/views/inbox.blade.php @@ -0,0 +1,196 @@ + + + + + + +{{ $title }} + + + + + +
+
+
+ +{{-- сёстры-планеты: все, кроме Hado --}} +
+
YUME
+
MYAKU
+
KIOKU
+
FUMI
+
+ +
+
+ HADO + @if($profile['badge'] > 0){{ $profile['badge'] }}@endif +
+
+ + 05 +
+
+ +
+ + + @foreach($groups as $group) +
+
{{ $group['ru'] }}
+ @if(empty($group['events']))
ПУСТО
@endif +
+ @foreach($group['events'] as $ev) +
+
{{ $ev['due_month'] }}{{ $ev['due_day'] }}@if($ev['due_time']){{ $ev['due_time'] }}@endif
+
+ @if($ev['link']){{ $ev['title'] }}@else{{ $ev['title'] }}@endif + @if($ev['subtitle']){{ $ev['subtitle'] }}@endif + {{ $ev['source'] }} · срок {{ $ev['due'] }} · {{ $ev['rel'] }} +
+
+ @if($ev['can_remind'])@endif + +
+
+ @endforeach +
+
+ @endforeach + +
+ + +
+ +
+ + +
+
+ +
波動 HADO· 世界 SEKAI
+ + + diff --git a/core/resources/views/welcome.blade.php b/core/resources/views/welcome.blade.php deleted file mode 100644 index 26e294a..0000000 --- a/core/resources/views/welcome.blade.php +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - - {{ config('app.name', 'Laravel') }} - - @fonts - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

With so many options available to you,
we suggest you start with the following:

- - - -

- v{{ app()->version() }} - - View changelog - - - - -

-
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- 13 --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - @if (Route::has('login')) - - @endif - - diff --git a/core/routes/web.php b/core/routes/web.php index d397c66..770d9a7 100644 --- a/core/routes/web.php +++ b/core/routes/web.php @@ -1,6 +1,7 @@ group(function () { + Route::get('/', InboxController::class); Route::get('/me', [ProfileController::class, 'show']); Route::patch('/me', [ProfileController::class, 'update']); Route::post('/me/heartbeat', [ProfileController::class, 'heartbeat']); diff --git a/core/tests/Feature/InboxPageTest.php b/core/tests/Feature/InboxPageTest.php new file mode 100644 index 0000000..9016a45 --- /dev/null +++ b/core/tests/Feature/InboxPageTest.php @@ -0,0 +1,91 @@ + 'UTC']); + CarbonImmutable::setTestNow('2026-09-04T12:00:00Z'); + } + + protected function tearDown(): void + { + CarbonImmutable::setTestNow(); + parent::tearDown(); + } + + public function test_requires_sso(): void + { + $this->get('/')->assertStatus(401); + } + + public function test_renders_groups_cards_archive_and_settings(): void + { + $this->get('/', ['X-Remote-User' => 'nikita']); + $user = User::sole(); + $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); + $manual = Source::create(['name' => 'manual', 'token_hash' => hash('sha256', 'y')]); + $make = fn (Source $src, string $topic, EventState $state, string $due, string $time = '24:00:00', ?string $doneLabel = null, ?string $link = '/x') => Event::create([ + 'source_id' => $src->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic, + 'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $due, + 'after_due' => AfterDue::Keep, 'payload' => array_filter(['title' => "Событие $topic", 'subtitle' => 'подзаголовок', 'deep_link' => $link, 'done_label' => $doneLabel]), + 'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), + ]); + $make($source, 'a', EventState::Today, '2026-09-04', '19:40:00', 'Поздравил'); + $make($source, 'b', EventState::Overdue, '2026-09-01'); + $make($manual, 'c', EventState::Preparing, '2026-09-30', '24:00:00', null, null); + $make($source, 'd', EventState::Scheduled, '2026-12-15'); + $make($source, 'e', EventState::Done, '2026-08-28', '24:00:00', 'Оплатил'); + $make($source, 'f', EventState::Expired, '2026-08-20'); + + $html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent(); + + // шапка и счётчик + $this->assertStringContainsString('(2) Hado', $html); + $this->assertStringContainsString('波動', $html); + $this->assertStringContainsString('+ Добавить', $html); + // группы в порядке и с кандзи + $this->assertMatchesRegularExpression('/今日.*СЕГОДНЯ.*遅延.*ПРОСРОЧЕНО.*近日.*ГРЯДЁТ.*予定.*ЗАПЛАНИРОВАНО/su', $html); + // карточки + $this->assertStringContainsString('Событие a', $html); + $this->assertStringContainsString('19:40', $html); + $this->assertStringContainsString('СЕН', $html); + $this->assertStringContainsString('сегодня', $html); + $this->assertStringContainsString('3 дня назад', $html); + $this->assertStringContainsString('через 26 дней', $html); + $this->assertStringContainsString('вручную', $html); + $this->assertStringContainsString('Поздравил', $html); + $this->assertStringContainsString('Помню', $html); + // архив + $this->assertStringContainsString('2 закрыто', $html); + $this->assertStringContainsString('Оплатил', $html); + $this->assertStringContainsString('Истекло', $html); + // настройки, форма добавления, присутствие + $this->assertStringContainsString('nikita', $html); + $this->assertStringContainsString('НОВОЕ СОБЫТИЕ', $html); + $this->assertStringContainsString('/inbox.js', $html); + $this->assertStringContainsString('csrf-token', $html); + } + + public function test_counter_hidden_when_zero(): void + { + $html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent(); + $this->assertStringContainsString('Hado', $html); + $this->assertStringNotContainsString('class="counter"', $html); + } +}