281 lines
13 KiB
PHP
281 lines
13 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* 家书 FamilyBook · 个人导航**展示页**(v24 第 24 批 用户第 4 条)
|
|||
|
|
*
|
|||
|
|
* ⚠️ 这是全站**唯一不需要登录**的页面 —— 用户明确要求,而且理由很实在:
|
|||
|
|
* 这个页面就是要"发给别人看 / 自己随手点开"的,要登录就没意义了。
|
|||
|
|
*
|
|||
|
|
* 用户原话:
|
|||
|
|
* 「现在的系统,每一个功能模块都是需要登录的。但是导航展示页面却不需要登录。
|
|||
|
|
* 这就意味着我们需要给他一个非常特殊的地址。地址后面可以跟着用户添加的一个短语,
|
|||
|
|
* 就跟类似于一个密钥一样,他可以给自己起一个短语。然后这个地址后面跟着问号
|
|||
|
|
* 带上这个短语的形式去进行展示。这样的话我们是为了区分不同的用户,他的不同的
|
|||
|
|
* 导航页面,用户选了一个短语之后,通过这个短语,我们就能知道要访问的是哪一个
|
|||
|
|
* 用户的面板,这个页面是不需要登录的。在这个页面里面要增加一个功能,右上角
|
|||
|
|
* 增加一个功能,就是登录,点击这个页面之后再进入到我们现在这个系统就是家书的
|
|||
|
|
* 这个系统的登录页面。否则的话,那个导航的展示的页面是跟之前的一样,是不需要
|
|||
|
|
* 进行登录的。这样的话,导航的展示页面,导航页的展示页面上面的那个头像就
|
|||
|
|
* 变成用户自己的头像就可以了。」
|
|||
|
|
*
|
|||
|
|
* ⇒ 地址形式:`https://<站点>/nav/?q=<短语>`(也兼容 `?<短语>`,见下面两种都收)
|
|||
|
|
* · 短语由用户自己起(可一键换随机码),全局唯一,就是这页的"凭据"
|
|||
|
|
* · 右上角恒有一个「登录」入口,点它进家书的登录页
|
|||
|
|
* · 页面顶部的头像用**他自己**的头像
|
|||
|
|
*
|
|||
|
|
* ⚠️ 安全上的三条硬约束(这是全站唯一免登录页,必须想清楚):
|
|||
|
|
* ① **只暴露这一个用户自己的导航数据** —— 所有查询都按短语解析出的 person_id 走,
|
|||
|
|
* 不接任何来自 URL 的 id(否则 `?q=xxx&person=2` 就能翻别人家);
|
|||
|
|
* ② **不返回任何身份/家庭信息** —— 只有导航本身 + 头像 + 站点名。
|
|||
|
|
* 连"这是谁"都不主动说(头像已经够了);
|
|||
|
|
* ③ 短语按固定形式校验(4~32 位字母数字_-),拿不到它就是 404(不提示"短语不存在"
|
|||
|
|
* 之外的信息,避免成为枚举靶子)。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
require __DIR__ . '/app/bootstrap.php';
|
|||
|
|
|
|||
|
|
use App\Nav;
|
|||
|
|
|
|||
|
|
// ---- 1. 取短语(两种写法都收:?q=xxx 与 ?xxx)----
|
|||
|
|
//
|
|||
|
|
// ⚠️ 为什么两种都收:用户描述的是「地址后面跟着问号带上这个短语」,
|
|||
|
|
// 也就是 `/nav/?yangyq` 这种**不带参数名**的形式;而现代浏览器/分享卡片
|
|||
|
|
// 更习惯 `?q=`。两种都认,用户怎么写都能进,省得他记规则。
|
|||
|
|
$phrase = trim((string)($_GET['q'] ?? ''));
|
|||
|
|
if ($phrase === '' && !empty($_SERVER['QUERY_STRING'])) {
|
|||
|
|
// `?yangyq` → QUERY_STRING 就是 `yangyq`(没有 `=` 号)
|
|||
|
|
$qs = (string)$_SERVER['QUERY_STRING'];
|
|||
|
|
if (strpos($qs, '=') === false) {
|
|||
|
|
$phrase = urldecode($qs);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$personId = $phrase === '' ? 0 : Nav::ownerByPhrase($phrase);
|
|||
|
|
|
|||
|
|
// ---- 2. 找不到就是 404(不给多余信息)----
|
|||
|
|
if ($personId <= 0) {
|
|||
|
|
http_response_code(404);
|
|||
|
|
header('Content-Type: text/html; charset=utf-8');
|
|||
|
|
?>
|
|||
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>没有这个导航页</title>
|
|||
|
|
<style>
|
|||
|
|
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
|||
|
|
background:#F7F4EF;color:#2C231D;
|
|||
|
|
font:15px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
|
|||
|
|
.b{max-width:420px;padding:28px 30px;background:#fff;border:1px solid rgba(44,35,29,.10);
|
|||
|
|
border-radius:16px;box-shadow:0 4px 16px rgba(44,35,29,.08);text-align:center}
|
|||
|
|
h1{margin:0 0 8px;font-size:18px}
|
|||
|
|
p{margin:0 0 16px;color:#7E6B5A;font-size:14px}
|
|||
|
|
a{display:inline-block;padding:8px 18px;border-radius:10px;background:#A84A28;color:#fff;
|
|||
|
|
text-decoration:none;font-size:14px}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="b">
|
|||
|
|
<h1>没有这个导航页</h1>
|
|||
|
|
<p>地址里的短语不对,或者主人换过短语了。<br>确认一下地址,或者找主人要新的。</p>
|
|||
|
|
<a href="<?= e(url('auth/login')) ?>">去家书登录</a>
|
|||
|
|
</div>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
<?php
|
|||
|
|
exit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- 3. 取这个人的导航数据(**只这一份**)----
|
|||
|
|
//
|
|||
|
|
// ⚠️ 这里开始的所有查询都只认 $personId,不再接任何请求参数。
|
|||
|
|
$gridGroups = Nav::groups($personId, 'grid', true);
|
|||
|
|
$tabGroups = Nav::groups($personId, 'tabs', true);
|
|||
|
|
$engines = Nav::engines($personId, true);
|
|||
|
|
$settings = Nav::settings($personId);
|
|||
|
|
|
|||
|
|
$sitesOf = static function (int $gid) use ($personId): array {
|
|||
|
|
return Nav::sites($personId, $gid);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
$mePerson = \App\Person::find($personId);
|
|||
|
|
$meAvatar = avatar_url($mePerson['avatar'] ?? null);
|
|||
|
|
|
|||
|
|
// 展示页自己的标题(用设置里的站点名与副标题;副标题允许为空 → 不显示)
|
|||
|
|
$navTitle = trim((string)($settings['site_name'] ?? '')) !== '' ? (string)$settings['site_name'] : '我的导航';
|
|||
|
|
$navSubtitle = (string)($settings['subtitle'] ?? '');
|
|||
|
|
$primary = Nav::safeColor((string)($settings['primary_color'] ?? ''), '#2F6BF7');
|
|||
|
|
$collLayout = (string)($settings['collections_layout'] ?? 'tabs') === 'flat' ? 'flat' : 'tabs';
|
|||
|
|
$openTarget = Nav::openTarget($personId);
|
|||
|
|
$openMode = $openTarget === '_blank' ? 'blank' : 'self';
|
|||
|
|
$suggestKeys = Nav::suggestKeys($personId);
|
|||
|
|
$default = Nav::defaultEngine($personId, $engines);
|
|||
|
|
|
|||
|
|
/** 渲染一个站点格(与 SimpleNav 的 site_tile 同构) */
|
|||
|
|
$siteTile = static function (array $s) use ($openTarget): void {
|
|||
|
|
$label = Nav::siteLabel($s);
|
|||
|
|
$src = Nav::iconUrl((string)($s['icon'] ?? ''));
|
|||
|
|
?>
|
|||
|
|
<li class="site">
|
|||
|
|
<a href="<?= e((string)$s['url']) ?>" target="<?= e($openTarget) ?>"<?= $openTarget === '_blank' ? ' rel="noopener"' : '' ?>
|
|||
|
|
title="<?= e(trim((string)($s['name'] ?? '')) !== '' ? (string)$s['name'] : $label) ?>">
|
|||
|
|
<?php if ($src !== ''): ?>
|
|||
|
|
<img class="icon" src="<?= e($src) ?>" alt="" loading="lazy">
|
|||
|
|
<?php else: ?>
|
|||
|
|
<span class="ico-letter" style="background:<?= e(Nav::letterColor($label)) ?>"><?= e(mb_substr($label, 0, 1, 'UTF-8')) ?></span>
|
|||
|
|
<?php endif; ?>
|
|||
|
|
<span class="name"><?= e($label) ?></span>
|
|||
|
|
</a>
|
|||
|
|
</li>
|
|||
|
|
<?php
|
|||
|
|
};
|
|||
|
|
?>
|
|||
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title><?= e($navTitle) ?><?= $navSubtitle !== '' ? ' · ' . e($navSubtitle) : '' ?></title>
|
|||
|
|
<meta name="robots" content="noindex,nofollow">
|
|||
|
|
<meta name="theme-color" content="<?= e($primary) ?>">
|
|||
|
|
<link rel="stylesheet" href="<?= e(asset('/assets/css/nav-show.css')) ?>">
|
|||
|
|
<?php if ($primary !== '#2F6BF7'): ?>
|
|||
|
|
<style>:root { --n-primary: <?= e($primary) ?>; }</style>
|
|||
|
|
<?php endif; ?>
|
|||
|
|
</head>
|
|||
|
|
<body data-open="<?= e($openMode) ?>">
|
|||
|
|
<div class="container">
|
|||
|
|
|
|||
|
|
<?php /* ⚠️ 右上角恒有「登录」入口(用户明确要求)。
|
|||
|
|
它进的是**家书**的登录页,不是这个导航系统的登录页 ——
|
|||
|
|
因为导航展示页本来就不需要登录,这个按钮是给"主人自己"用的便利入口。 */ ?>
|
|||
|
|
<div class="topline">
|
|||
|
|
<div class="who">
|
|||
|
|
<?php if ($meAvatar !== ''): ?>
|
|||
|
|
<img class="who-av" src="<?= e($meAvatar) ?>" alt="">
|
|||
|
|
<?php endif; ?>
|
|||
|
|
<span class="who-name"><?= e($navTitle) ?></span>
|
|||
|
|
</div>
|
|||
|
|
<a class="login-btn" href="<?= e(url('auth/login')) ?>">
|
|||
|
|
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor"
|
|||
|
|
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|||
|
|
<path d="M12.5 6.5V5a1.5 1.5 0 0 0-1.5-1.5H5A1.5 1.5 0 0 0 3.5 5v10A1.5 1.5 0 0 0 5 16.5h6a1.5 1.5 0 0 0 1.5-1.5v-1.5"/>
|
|||
|
|
<path d="M8 10h9m0 0-2.5-2.5M17 10l-2.5 2.5"/>
|
|||
|
|
</svg>
|
|||
|
|
登录
|
|||
|
|
</a>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<header class="hero">
|
|||
|
|
<?php if ($meAvatar !== ''): ?>
|
|||
|
|
<img class="logo" src="<?= e($meAvatar) ?>" alt="">
|
|||
|
|
<?php endif; ?>
|
|||
|
|
<h1><?= e($navTitle) ?></h1>
|
|||
|
|
<?php if ($navSubtitle !== ''): ?>
|
|||
|
|
<p><?= e($navSubtitle) ?></p>
|
|||
|
|
<?php endif; ?>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<?php if ($engines): ?>
|
|||
|
|
<div class="s-search">
|
|||
|
|
<div class="search-type">
|
|||
|
|
<?php foreach ($engines as $en): ?>
|
|||
|
|
<label class="s-engine<?= $default && $en['slug'] === $default['slug'] ? ' active' : '' ?>"
|
|||
|
|
data-search="<?= e((string)$en['search_url']) ?>"
|
|||
|
|
data-param="<?= e((string)$en['param']) ?>"
|
|||
|
|
data-slug="<?= e((string)$en['slug']) ?>"
|
|||
|
|
data-placeholder="<?= e((string)$en['name']) ?>搜索"
|
|||
|
|
style="--ec:<?= e(Nav::safeColor((string)$en['color'], '#2F6BF7')) ?>">
|
|||
|
|
<span><?= e((string)$en['name']) ?></span>
|
|||
|
|
</label>
|
|||
|
|
<?php endforeach; ?>
|
|||
|
|
</div>
|
|||
|
|
<form id="search-form" action="<?= e((string)($default['search_url'] ?? 'https://cn.bing.com/search')) ?>"
|
|||
|
|
method="get" target="<?= e($openTarget) ?>"<?= $openTarget === '_blank' ? ' rel="noopener"' : '' ?>
|
|||
|
|
data-slug="<?= e((string)($default['slug'] ?? '')) ?>" data-keys="<?= e($suggestKeys) ?>">
|
|||
|
|
<input type="text" id="search-text" name="<?= e((string)($default['param'] ?? 'q')) ?>"
|
|||
|
|
placeholder="<?= e((string)($default['name'] ?? '搜索')) ?>搜索" autocomplete="off">
|
|||
|
|
<button class="submit" type="submit" aria-label="搜索">
|
|||
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
|
|||
|
|
stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
|
|||
|
|
<circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.6-3.6"></path>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
<ul id="word"></ul>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
<?php endif; ?>
|
|||
|
|
|
|||
|
|
<?php foreach ($gridGroups as $g): $items = $sitesOf((int)$g['id']); if (!$items) { continue; } ?>
|
|||
|
|
<ul class="mylist">
|
|||
|
|
<li class="title"><?= e((string)$g['name']) ?></li>
|
|||
|
|
<?php foreach ($items as $s) { $siteTile($s); } ?>
|
|||
|
|
</ul>
|
|||
|
|
<?php endforeach; ?>
|
|||
|
|
|
|||
|
|
<?php
|
|||
|
|
$collGroups = [];
|
|||
|
|
foreach ($tabGroups as $g) {
|
|||
|
|
$items = $sitesOf((int)$g['id']);
|
|||
|
|
if ($items) { $collGroups[] = ['g' => $g, 'items' => $items]; }
|
|||
|
|
}
|
|||
|
|
?>
|
|||
|
|
<?php if ($collGroups): ?>
|
|||
|
|
<div class="collections<?= $collLayout === 'flat' ? ' flat' : '' ?>" data-layout="<?= e($collLayout) ?>">
|
|||
|
|
<div class="collections-title">合集</div>
|
|||
|
|
<?php if ($collLayout === 'flat'): ?>
|
|||
|
|
<?php foreach ($collGroups as $c): ?>
|
|||
|
|
<ul class="mylist">
|
|||
|
|
<li class="title"><?= e((string)$c['g']['name']) ?></li>
|
|||
|
|
<?php foreach ($c['items'] as $s) { $siteTile($s); } ?>
|
|||
|
|
</ul>
|
|||
|
|
<?php endforeach; ?>
|
|||
|
|
<?php else: ?>
|
|||
|
|
<div class="tabs" role="tablist">
|
|||
|
|
<?php foreach ($collGroups as $i => $c): ?>
|
|||
|
|
<button type="button" class="tab<?= $i === 0 ? ' active' : '' ?>" role="tab"
|
|||
|
|
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
|
|||
|
|
data-target="coll-<?= (int)$c['g']['id'] ?>"><?= e((string)$c['g']['name']) ?></button>
|
|||
|
|
<?php endforeach; ?>
|
|||
|
|
</div>
|
|||
|
|
<div class="tab-panels">
|
|||
|
|
<?php foreach ($collGroups as $i => $c): ?>
|
|||
|
|
<ul class="mylist tab-panel<?= $i === 0 ? ' active' : '' ?>" id="coll-<?= (int)$c['g']['id'] ?>" role="tabpanel">
|
|||
|
|
<?php foreach ($c['items'] as $s) { $siteTile($s); } ?>
|
|||
|
|
</ul>
|
|||
|
|
<?php endforeach; ?>
|
|||
|
|
</div>
|
|||
|
|
<?php endif; ?>
|
|||
|
|
</div>
|
|||
|
|
<?php endif; ?>
|
|||
|
|
|
|||
|
|
<?php /* ⚠️ 空态:主人**还没在导航管理页里加过东西**时(`Nav::ensureSeed` 只在管理页跑,
|
|||
|
|
展示页故意**不写数据** —— 它是一个免登录页面,不该被任何拿到短语的人触发出写操作),
|
|||
|
|
这里会一块内容都没有。空白的公开页看起来像"坏了",所以要给一句话。 */ ?>
|
|||
|
|
<?php if (!$gridGroups && !$collGroups): ?>
|
|||
|
|
<div class="nav-empty">
|
|||
|
|
<p>这个导航页还是空的 —— 主人还没往里加东西。</p>
|
|||
|
|
<p class="small muted">(去「导航」页加几个常用网站,这里就有了。)</p>
|
|||
|
|
</div>
|
|||
|
|
<?php endif; ?>
|
|||
|
|
|
|||
|
|
<footer class="foot">
|
|||
|
|
<a href="<?= e(url('auth/login')) ?>">登录家书</a>
|
|||
|
|
<span>·</span>
|
|||
|
|
<span><?= e($navTitle) ?></span>
|
|||
|
|
</footer>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
/* 联想词来源:走家书自己的代理(不直接把第三方接口暴露给浏览器 ——
|
|||
|
|
SimpleNav 原来也是走自己的 suggest.php,同一个理由:跨域 + 不必要的直连)。 */
|
|||
|
|
window.SIMPLENAV = {
|
|||
|
|
suggest: <?= json_encode(url('nav/suggest') . '?p=' . rawurlencode($phrase), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
|
|||
|
|
suggestKeys: <?= json_encode($suggestKeys) ?>
|
|||
|
|
};
|
|||
|
|
</script>
|
|||
|
|
<script src="<?= e(asset('/assets/js/nav-show.js')) ?>"></script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|