feat: web inbox per Sekai design handoff

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
nikita.hohlov
2026-09-04 08:16:04 -03:00
parent 75ac268c53
commit 027c9f7784
6 changed files with 484 additions and 223 deletions

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\Enums\EventState;
use App\Http\Controllers\Api\EventsController as Presenter;
use App\Http\Controllers\Me\ChannelsController;
use App\Http\Controllers\Me\ProfileController;
use App\Models\Event;
use App\Models\User;
use App\Scheduling\DueResolver;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
class InboxController extends Controller
{
private const MONTHS = ['ЯНВ', 'ФЕВ', 'МАР', 'АПР', 'МАЙ', 'ИЮН', 'ИЮЛ', 'АВГ', 'СЕН', 'ОКТ', 'НОЯ', 'ДЕК'];
public function __construct(private readonly DueResolver $due) {}
public function __invoke(Request $request): View
{
/** @var User $user */
$user = $request->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<string,mixed> */
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)).' назад',
};
}
}

92
core/public/inbox.js Normal file
View File

@@ -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(); } });
})();

View File

@@ -0,0 +1,196 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;700&family=Space+Grotesk:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root{--bg:#070912;--ink-0:#090B16;--ink-1:#11152E;--ink-2:#1B2148;--ink-3:#2B3566;--sakura:#F7CEDC;--nadeshiko:#EA8FAE;--tsutsuji:#E84E8A;--kobai:#C13E6A;--yamabuki:#F2B233;--hanada:#1F7FC4;--ruri:#355CD6;--byakugun:#8FD6DC;--fuji:#9F8FCB;--washi:#F2ECE0;--kasumi:#AEB4D6;--nibi:#6B7099;--hair:rgba(174,180,214,.16);--hair-2:rgba(174,180,214,.30)}
*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg)}
body{min-height:100vh;color:var(--washi);font-family:'Space Grotesk',sans-serif;position:relative;overflow-x:hidden;display:flex;flex-direction:column}
::selection{background:rgba(143,214,220,.25)}
a{color:var(--byakugun);text-decoration-color:rgba(143,214,220,.4)}a:hover{color:var(--washi)}
input,select,button{font:inherit}button[disabled]{opacity:.45;pointer-events:none}
.mono{font-family:'Space Mono',monospace}.serif{font-family:'Shippori Mincho',serif}
.layer{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0}
.nebula{position:absolute;pointer-events:none;z-index:0;top:26%;left:-22%;width:150%;height:52%;filter:blur(34px);opacity:.72;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 42% 58% at 20% 60%,rgba(234,143,174,.20),transparent 70%),radial-gradient(ellipse 58% 46% at 48% 44%,rgba(232,78,138,.14),transparent 72%),radial-gradient(ellipse 38% 54% at 70% 57%,rgba(159,143,203,.17),transparent 70%),radial-gradient(ellipse 28% 40% at 88% 51%,rgba(143,214,220,.11),transparent 70%),radial-gradient(ellipse 52% 34% at 36% 62%,rgba(53,92,214,.11),transparent 72%)}
.core{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(14px);opacity:.7;animation:skNebulaCore 58s ease-in-out infinite;background:radial-gradient(ellipse 20% 70% at 8% 71%,rgba(247,206,220,.22),transparent 70%),radial-gradient(ellipse 14% 90% at 22% 52%,rgba(242,236,224,.18),transparent 70%),radial-gradient(ellipse 18% 80% at 36% 48%,rgba(234,143,174,.22),transparent 72%),radial-gradient(ellipse 12% 100% at 48% 38%,rgba(247,206,220,.24),transparent 70%),radial-gradient(ellipse 16% 85% at 60% 43%,rgba(242,236,224,.17),transparent 72%),radial-gradient(ellipse 13% 75% at 72% 43%,rgba(234,143,174,.20),transparent 70%),radial-gradient(ellipse 18% 90% at 86% 55%,rgba(247,206,220,.18),transparent 72%),radial-gradient(ellipse 10% 60% at 94% 61%,rgba(143,214,220,.12),transparent 70%)}
.dust{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;filter:blur(10px);opacity:.8;animation:skNebula 46s ease-in-out infinite;background:radial-gradient(ellipse 20% 38% at 16% 72%,rgba(7,9,18,.6),transparent 75%),radial-gradient(ellipse 28% 34% at 44% 55%,rgba(9,11,22,.55),transparent 75%),radial-gradient(ellipse 18% 38% at 70% 50%,rgba(7,9,18,.55),transparent 75%),radial-gradient(ellipse 14% 30% at 90% 68%,rgba(9,11,22,.5),transparent 75%)}
.band{position:absolute;pointer-events:none;z-index:0;top:44%;left:-15%;width:135%;height:14%;transform:rotate(-14deg)}
.sister{position:absolute}.sister .col{display:flex;flex-direction:column;align-items:center;gap:9px}
.skdot{pointer-events:auto;border-radius:50%}.sklbl{font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.3em;color:var(--nibi);white-space:nowrap;opacity:0;transition:opacity .4s}.skdot:hover+.sklbl{opacity:1}
header{position:relative;z-index:1;display:flex;justify-content:space-between;align-items:flex-start;padding:30px 44px 0}
.brand{display:flex;align-items:baseline;gap:14px}.brand .jp{font-family:'Shippori Mincho',serif;font-size:54px;line-height:1}.brand .lat{font-family:'Space Mono',monospace;font-size:13px;letter-spacing:.5em;color:var(--nibi)}
.counter{display:inline-flex;align-items:center;gap:8px;padding:5px 12px 5px 10px;border:1px solid rgba(53,92,214,.55);border-radius:999px;background:rgba(53,92,214,.14)}
.counter .dot{width:11px;height:11px;border-radius:50%;background:radial-gradient(circle at 32% 28%,var(--ruri),#24337F 55%,var(--ink-0) 92%);animation:skPulseDot 2.6s ease-in-out infinite}
.counter .n{font-family:'Space Mono',monospace;font-size:12px;color:var(--byakugun)}
.tools{display:flex;align-items:center;gap:16px;margin-top:14px}.num{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)}
.btn{border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer;transition:background .2s,border-color .2s,color .2s}
.primary{background:rgba(53,92,214,.22);border:1px solid var(--ruri);color:var(--byakugun)}.primary:hover{background:rgba(53,92,214,.4)}
.ghost{background:transparent;border:1px solid var(--hair-2);color:var(--kasumi)}.ghost:hover{border-color:var(--byakugun);color:var(--byakugun)}
.ghost.danger:hover{border-color:var(--nadeshiko);color:var(--sakura)}
main{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:72px 32px 100px;flex:1}
.panel{border:1px solid var(--hair);border-radius:14px;background:rgba(17,21,46,.7);overflow:hidden;margin-top:64px}
.panel>button.head{width:100%;display:flex;align-items:center;gap:14px;background:none;border:none;padding:18px 22px;cursor:pointer;text-align:left;color:var(--washi)}
.panel .head .jp{font-family:'Shippori Mincho',serif;font-size:16px}.panel .head .meta{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;color:var(--nibi)}.panel .head .chev{margin-left:auto;font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi)}
.panel .body{border-top:1px solid var(--hair);padding:18px 22px;display:flex;flex-direction:column;gap:10px}
.addform{margin-bottom:52px;border:1px dashed var(--hair-2);border-radius:14px;background:rgba(17,21,46,.7);padding:22px}
.label{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--kasumi);margin-bottom:16px}
.row{display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end}
.field{display:flex;flex-direction:column;gap:6px}.field span{font-size:12px;color:var(--nibi)}
.field input,.field select{background:var(--ink-1);border:1px solid var(--hair-2);border-radius:8px;color:var(--washi);padding:9px 12px;font-size:14px}
.err{color:var(--sakura);font-size:12px;margin-top:12px}.ok{color:var(--byakugun);font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.15em;margin-top:12px}
.group{margin-bottom:52px}.group .h{display:flex;align-items:baseline;gap:12px;margin-bottom:18px}.group .h .jp{font-family:'Shippori Mincho',serif;font-size:20px}.group .h .ru{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.35em;color:var(--kasumi)}.group .h .line{flex:1;border-bottom:1px solid var(--hair)}
.empty{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi);padding:12px 2px}
.cards{display:flex;flex-direction:column;gap:12px}
.card{background:rgba(27,33,72,.72);border:1px solid var(--hair);border-radius:12px;padding:16px 20px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
.plaque{display:flex;flex-direction:column;align-items:center;gap:2px;min-width:64px;padding:8px 10px;border:1px solid var(--hair-2);border-radius:10px;background:var(--ink-1)}
.plaque .m{font-family:'Space Mono',monospace;font-size:9px;letter-spacing:.2em;color:var(--nibi)}.plaque .d{font-family:'Shippori Mincho',serif;font-size:24px;line-height:1}.plaque .t{font-family:'Space Mono',monospace;font-size:10px;color:var(--byakugun)}
.card .text{flex:1;min-width:220px;display:flex;flex-direction:column;gap:5px}
.card .title{font-size:16px;font-weight:500;color:var(--washi);align-self:flex-start}.card a.title{text-decoration:underline dotted var(--hair-2);text-underline-offset:4px}.card a.title:hover{color:var(--byakugun)}
.card .sub{font-size:13px;color:var(--kasumi)}.card .meta{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi);margin-top:3px}.card .meta .rel{color:var(--kasumi)}
.card .actions{display:flex;gap:10px;align-items:center}
.arow{display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1);opacity:.75}
.arow .t{font-size:14px;color:var(--kasumi);flex:1;min-width:180px}.arow .m{font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.12em;color:var(--nibi)}.arow .tag{font-family:'Space Mono',monospace;font-size:10px;letter-spacing:.15em;color:var(--fuji);border:1px solid var(--hair);border-radius:999px;padding:3px 10px}
.chrow{display:flex;align-items:center;gap:14px;border:1px solid var(--hair);border-radius:10px;padding:11px 16px;background:var(--ink-1)}.chrow .n{font-size:14px;min-width:76px}.chrow .note{font-family:'Space Mono',monospace;font-size:11px;color:var(--nibi);flex:1;overflow-wrap:anywhere}
.tgbox{border:1px dashed var(--hair-2);border-radius:10px;padding:14px 18px;display:flex;flex-direction:column;gap:8px}
footer{position:relative;z-index:1;max-width:880px;width:100%;margin:0 auto;padding:22px 32px 36px;border-top:1px solid var(--hair);display:flex;justify-content:center;gap:8px;font-family:'Space Mono',monospace;font-size:11px;letter-spacing:.3em;color:var(--nibi)}
@keyframes skTwinkle{0%,100%{opacity:.15}50%{opacity:.75}}
@keyframes skNebula{0%,100%{transform:rotate(-14deg) translateX(-2.5%)}50%{transform:rotate(-14deg) translateX(2.5%)}}
@keyframes skNebulaCore{0%,100%{transform:rotate(-14deg) translateX(3%)}50%{transform:rotate(-14deg) translateX(-3%)}}
@keyframes skOrbXa{0%,100%{transform:translateX(36px)}50%{transform:translateX(-36px)}}@keyframes skOrbYa{0%,100%{transform:translateY(13px)}50%{transform:translateY(-13px)}}
@keyframes skOrbXb{0%,100%{transform:translateX(24px)}50%{transform:translateX(-24px)}}@keyframes skOrbYb{0%,100%{transform:translateY(22px)}50%{transform:translateY(-22px)}}
@keyframes skOrbXc{0%,100%{transform:translateX(14px)}50%{transform:translateX(-14px)}}@keyframes skOrbYc{0%,100%{transform:translateY(28px)}50%{transform:translateY(-28px)}}
@keyframes skOrbXd{0%,100%{transform:translateX(-20px)}50%{transform:translateX(20px)}}@keyframes skOrbYd{0%,100%{transform:translateY(16px)}50%{transform:translateY(-16px)}}
@keyframes skPulseDot{0%,100%{box-shadow:0 0 6px rgba(53,92,214,.5)}50%{box-shadow:0 0 14px rgba(53,92,214,.95)}}
</style>
</head>
<body>
<div class="layer" id="stars"></div>
<div class="nebula"></div><div class="core"></div><div class="dust"></div>
<div class="band" id="band"></div>
{{-- сёстры-планеты: все, кроме Hado --}}
<div class="layer">
<div class="sister" style="top:17%;left:7%"><div style="animation:skOrbXa 19s ease-in-out infinite"><div class="col" style="animation:skOrbYa 19s ease-in-out -4.75s infinite"><div class="skdot" style="width:15px;height:15px;background:radial-gradient(circle at 32% 28%,var(--sakura),var(--nadeshiko) 50%,var(--ink-0) 92%);box-shadow:0 0 10px rgba(234,143,174,.4)"></div><span class="sklbl">YUME</span></div></div></div>
<div class="sister" style="top:26%;right:8%"><div style="animation:skOrbXb 27s ease-in-out infinite"><div class="col" style="animation:skOrbYb 27s ease-in-out -20.25s infinite"><div class="skdot" style="width:12px;height:12px;background:radial-gradient(circle at 32% 28%,var(--tsutsuji),var(--kobai) 52%,var(--ink-0) 92%);box-shadow:0 0 9px rgba(232,78,138,.4)"></div><span class="sklbl">MYAKU</span></div></div></div>
<div class="sister" style="top:60%;left:4.5%"><div style="animation:skOrbXc 14s ease-in-out infinite"><div class="col" style="gap:8px;animation:skOrbYc 14s ease-in-out -3.5s infinite"><div class="skdot" style="width:9px;height:9px;background:radial-gradient(circle at 34% 30%,var(--fuji),#4A4177 55%,var(--ink-0) 92%);box-shadow:0 0 8px rgba(159,143,203,.4)"></div><span class="sklbl">KIOKU</span></div></div></div>
<div class="sister" style="top:64%;right:6%"><div style="animation:skOrbXd 22s ease-in-out infinite"><div class="col" style="gap:8px;animation:skOrbYd 22s ease-in-out -5.5s infinite"><div class="skdot" style="width:10px;height:10px;background:radial-gradient(circle at 32% 28%,var(--byakugun),var(--hanada) 50%,var(--ink-0) 93%);box-shadow:0 0 9px rgba(143,214,220,.4)"></div><span class="sklbl">FUMI</span></div></div></div>
</div>
<header>
<div class="brand">
<span class="jp">波動</span><span class="lat">HADO</span>
@if($profile['badge'] > 0)<span class="counter"><span class="dot"></span><span class="n">{{ $profile['badge'] }}</span></span>@endif
</div>
<div class="tools">
<button class="btn primary" id="toggle-add">+ Добавить</button>
<span class="num">05</span>
</div>
</header>
<main>
<section class="addform" id="addform" hidden>
<div class="label">НОВОЕ СОБЫТИЕ</div>
<form class="row" id="add">
<label class="field" style="flex:2;min-width:220px"><span>Заголовок</span><input name="title" placeholder="Продлить страховку" required maxlength="200"></label>
<label class="field" style="flex:1;min-width:160px"><span>Подзаголовок (опц.)</span><input name="subtitle" placeholder="раз в год" maxlength="200"></label>
<label class="field"><span>Срок</span><input name="due_date" type="date" required></label>
<label class="field"><span>Время (опц.)</span><input name="due_time" type="time"></label>
<button type="submit" class="btn primary">Создать</button>
<button type="button" class="btn ghost" id="cancel-add">Отмена</button>
</form>
<div class="err" id="add-err" hidden></div>
</section>
@foreach($groups as $group)
<section class="group">
<div class="h"><span class="jp">{{ $group['jp'] }}</span><span class="ru">{{ $group['ru'] }}</span><span class="line"></span></div>
@if(empty($group['events']))<div class="empty">ПУСТО</div>@endif
<div class="cards">
@foreach($group['events'] as $ev)
<article class="card" data-id="{{ $ev['id'] }}">
<div class="plaque"><span class="m">{{ $ev['due_month'] }}</span><span class="d">{{ $ev['due_day'] }}</span>@if($ev['due_time'])<span class="t">{{ $ev['due_time'] }}</span>@endif</div>
<div class="text">
@if($ev['link'])<a class="title" href="{{ $ev['link'] }}">{{ $ev['title'] }}</a>@else<span class="title">{{ $ev['title'] }}</span>@endif
@if($ev['subtitle'])<span class="sub">{{ $ev['subtitle'] }}</span>@endif
<span class="meta">{{ $ev['source'] }} · срок {{ $ev['due'] }} · <span class="rel">{{ $ev['rel'] }}</span></span>
</div>
<div class="actions">
@if($ev['can_remind'])<button class="btn ghost" data-action="ack">Помню</button>@endif
<button class="btn primary" data-action="done">{{ $ev['done_label'] }}</button>
</div>
</article>
@endforeach
</div>
</section>
@endforeach
<section class="panel" data-panel="archive">
<button class="head" type="button"><span class="jp">書庫</span><span>Архив</span><span class="meta">{{ count($archive) }} закрыто</span><span class="chev"></span></button>
<div class="body" hidden>
@if(empty($archive))<div class="empty">ПУСТО</div>@endif
@foreach($archive as $ar)
<div class="arow"><span class="t">{{ $ar['title'] }}</span><span class="m">{{ $ar['source'] }} · {{ $ar['due'] }}</span><span class="tag">{{ $ar['closed_as'] }}</span></div>
@endforeach
</div>
</section>
<section class="panel" data-panel="settings">
<button class="head" type="button"><span class="jp">設定</span><span>Настройки</span><span class="meta">{{ $profile['login'] }}</span><span class="chev"></span></button>
<div class="body" hidden style="padding:26px 22px 30px;gap:34px">
<div>
<div class="label">ПОЯС И ТИХИЕ ЧАСЫ</div>
<form class="row" id="profile">
<label class="field" style="min-width:240px"><span>Часовой пояс</span>
<select name="tz">@foreach($timezones as $tz)<option value="{{ $tz }}" @selected($tz === $profile['tz'])>{{ $tz }}</option>@endforeach</select>
</label>
<label class="field"><span>Тихие часы, с</span><input name="quiet_start" type="time" value="{{ $profile['quiet_start'] }}"></label>
<label class="field"><span>до</span><input name="quiet_end" type="time" value="{{ $profile['quiet_end'] }}"></label>
<button type="submit" class="btn primary">Сохранить</button>
</form>
<div class="err" id="profile-err" hidden></div>
</div>
<div>
<div class="label">КАНАЛЫ</div>
<div style="display:flex;flex-direction:column;gap:10px">
@foreach($channels as $ch)
<div class="chrow">
<span class="n">{{ ['web' => 'Веб', 'telegram' => 'Telegram', 'webhook' => 'Webhook'][$ch['type']] }}</span>
<span class="note">{{ $ch['type'] === 'web' ? 'этот инбокс' : ($ch['type'] === 'telegram' ? 'чат '.$ch['config']['chat_id'] : $ch['config']['deliver_url']) }}</span>
@if($ch['type'] !== 'web')<button class="btn ghost danger" style="padding:6px 12px;font-size:12px" data-delete-channel="{{ $ch['id'] }}">Удалить</button>@endif
</div>
@endforeach
</div>
</div>
<div>
<div class="label">ДОБАВИТЬ WEBHOOK</div>
<form class="row" id="webhook">
<label class="field" style="flex:1;min-width:220px"><span>Адрес доставки</span><input name="deliver_url" type="url" placeholder="https://home.local/hado/notify" required></label>
<label class="field" style="flex:1;min-width:220px"><span>Адрес присутствия</span><input name="presence_url" type="url" placeholder="https://home.local/presence" required></label>
<button type="submit" class="btn ghost">Добавить webhook</button>
</form>
<div class="err" id="webhook-err" hidden></div>
</div>
<div>
<div class="label">TELEGRAM</div>
<button class="btn ghost" id="tg-connect">Подключить Telegram</button>
<div class="tgbox" id="tg-box" hidden>
<span style="font-size:14px">Отправь боту <span class="mono" style="color:var(--byakugun)" id="tg-code"></span> или <a id="tg-link" href="#" target="_blank" rel="noopener">открой</a></span>
<span class="mono" style="font-size:11px;letter-spacing:.15em;color:var(--nibi)">Код живёт 15 минут. Канал появится в списке при следующем открытии страницы.</span>
</div>
</div>
</div>
</section>
</main>
<footer><span>波動 HADO</span><span>· 世界 SEKAI</span></footer>
<script src="/inbox.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\ActionController;
use App\Http\Controllers\InboxController;
use App\Http\Controllers\Me\ChannelsController;
use App\Http\Controllers\Me\EventsController as MeEvents;
use App\Http\Controllers\Me\ProfileController;
@@ -14,6 +15,7 @@ Route::post('/hooks/telegram', TelegramWebhookController::class);
// Под SSO хаба
Route::middleware('remote.user')->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']);

View File

@@ -0,0 +1,91 @@
<?php
namespace Tests\Feature;
use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\Source;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class InboxPageTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config(['hado.default_tz' => '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('<title>(2) Hado</title>', $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('<title>Hado</title>', $html);
$this->assertStringNotContainsString('class="counter"', $html);
}
}