Files
tamabox/templates/user/explore.html
T
2026-10-19 11:08:30 +08:00

622 lines
21 KiB
HTML

{{template "base/header" .}}
<style>
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.explore-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
margin-right: 15px;
}
.explore-info {
flex: 1;
min-width: 0;
}
.explore-name {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 5px;
}
.explore-name a {
text-decoration: none;
color: var(--tb-text);
}
.explore-name a:hover {
color: var(--tb-primary);
}
.explore-intro {
color: var(--tb-text-secondary);
margin-bottom: 10px;
font-size: 0.9rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.6em;
}
.explore-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.explore-tags .uk-label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recommend-card {
display: flex;
align-items: center;
padding: 10px;
background: var(--tb-surface-2);
border-radius: 8px;
transition: background 0.2s;
}
.recommend-card:hover {
background: #f0f0f0;
}
.recommend-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 10px;
}
.recommend-info {
flex: 1;
min-width: 0;
}
.recommend-name {
font-weight: bold;
font-size: 1rem;
}
.recommend-name a {
text-decoration: none;
color: var(--tb-text);
}
.recommend-tags {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 3px;
}
.recommend-tags .uk-label {
font-size: 0.7rem;
padding: 0 5px;
}
.hot-votes-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.hot-vote-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 16px 18px;
border: 1px solid var(--tb-border);
border-radius: 8px;
background: var(--tb-surface-2);
text-decoration: none;
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
}
.hot-vote-item:hover {
border-color: var(--tb-primary);
background: #f5faff;
text-decoration: none;
transform: translateY(-1px);
}
.hot-vote-content {
min-width: 0;
}
.hot-vote-title {
display: block;
overflow: hidden;
color: var(--tb-text);
font-size: 0.98rem;
font-weight: 600;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.hot-vote-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin-top: 8px;
}
.hot-vote-meta .uk-label {
font-size: 0.72rem;
line-height: 1.6;
}
.hot-vote-action {
flex: 0 0 auto;
color: var(--tb-primary);
font-size: 0.82rem;
white-space: nowrap;
}
html[data-theme="dark"] .hot-vote-item {
border-color: var(--tb-border);
background: var(--tb-surface-2);
}
html[data-theme="dark"] .hot-vote-item:hover {
border-color: var(--tb-primary);
background: rgba(78, 168, 245, 0.10);
}
html[data-theme="dark"] .hot-vote-title {
color: var(--tb-text);
}
@media (max-width: 640px) {
.hot-votes-list {
grid-template-columns: 1fr;
}
.hot-vote-item {
padding: 14px;
}
}
.explore-tabs {
display: flex;
justify-content: center;
gap: 4px;
padding: 4px;
margin: 2px auto 20px;
max-width: 320px;
border: 1px solid var(--tb-border);
border-radius: 9px;
background: var(--tb-surface-2);
}
.explore-tab {
border: 0;
border-radius: 6px;
padding: 8px 15px;
background: transparent;
color: var(--tb-text-secondary);
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s ease, color 0.2s ease;
}
.explore-tab:hover { color: var(--tb-primary); }
.explore-tab.is-active {
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.10);
color: var(--tb-primary);
font-weight: 600;
}
.explore-panel[hidden] { display: none !important; }
.explore-vote-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.explore-vote-toolbar p { margin: 4px 0 0; }
.vote-random-btn {
flex: 0 0 auto;
border: 1px solid #dce9ff;
border-radius: 7px;
padding: 7px 11px;
color: var(--tb-primary);
background: #f5faff;
cursor: pointer;
font-size: 0.84rem;
}
.vote-random-btn:hover { border-color: var(--tb-primary); background: #edf6ff; }
.all-votes-list { display: grid; gap: 10px; }
.all-vote-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-bottom: 1px solid var(--tb-border);
text-decoration: none;
}
.all-vote-item:first-child { border-top: 1px solid var(--tb-border); }
.all-vote-item:hover { background: #f8fbff; text-decoration: none; }
.all-vote-item .hot-vote-title { max-width: 100%; }
html[data-theme="dark"] .explore-tabs {
border-color: var(--tb-border);
background: var(--tb-surface-2);
}
html[data-theme="dark"] .explore-tab { color: var(--tb-text-muted); }
html[data-theme="dark"] .explore-tab.is-active { background: var(--tb-surface); color: var(--tb-primary); }
html[data-theme="dark"] .vote-random-btn { border-color: var(--tb-border); background: var(--tb-surface-2); color: var(--tb-primary); }
html[data-theme="dark"] .all-vote-item { border-color: var(--tb-border); }
html[data-theme="dark"] .all-vote-item:hover { background: rgba(78, 168, 245, 0.10); }
.explore-notice {
background: #f0f7ff;
border-left: 4px solid var(--tb-primary);
padding: 15px 20px;
margin-bottom: 20px;
border-radius: 4px;
color: var(--tb-text);
font-size: 0.9rem;
line-height: 1.6;
}
.explore-notice strong {
color: var(--tb-primary);
}
/* 极简「换一批」按钮:描边胶囊 + 悬停浅蓝底色 + 点击旋转反馈 */
.tb-shuffle-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
font-size: 0.85rem;
line-height: 1.4;
color: var(--tb-primary);
background: transparent;
border: 1px solid #e3e3e3;
border-radius: 999px;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.tb-shuffle-btn:hover {
background: #f0f7ff;
border-color: var(--tb-primary);
}
.tb-shuffle-btn:active {
transform: scale(0.96);
}
.tb-shuffle-btn .uk-icon {
transition: transform 0.5s ease;
}
.tb-shuffle-btn.is-spinning .uk-icon {
transform: rotate(360deg);
}
html[data-theme="dark"] .tb-shuffle-btn {
color: #4ea8f5;
border-color: #3a3f45;
}
html[data-theme="dark"] .tb-shuffle-btn:hover {
background: rgba(78, 168, 245, 0.12);
border-color: #4ea8f5;
}
</style>
<div class="uk-container uk-container-small">
<h2>{{t .LangCode "explore.title"}}</h2>
<div class="explore-notice">
<p>{{t .LangCode "explore.notice" | safe}}</p>
</div>
<div class="explore-tabs" role="tablist" aria-label="{{t .LangCode "explore.title"}}">
<button type="button" class="explore-tab is-active" data-explore-tab="questions" role="tab" aria-selected="true">{{t .LangCode "explore.questionsTab"}}</button>
<button type="button" class="explore-tab" data-explore-tab="votes" role="tab" aria-selected="false">{{t .LangCode "explore.votesTab"}}</button>
</div>
<div class="explore-panel" data-explore-panel="questions">
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<div class="uk-flex uk-flex-between uk-flex-middle">
<h3 class="uk-card-title uk-margin-remove">{{t .LangCode "explore.recommend"}}</h3>
<button class="tb-shuffle-btn" id="refresh-recommend" type="button">
<span uk-icon="icon: refresh"></span> {{t .LangCode "explore.shuffle"}}
</button>
</div>
<div id="recommend-list" class="uk-grid-small uk-child-width-1-2@s uk-child-width-1-3@m uk-grid-match" uk-grid>
{{if .RecommendUsers}}
{{range $user := .RecommendUsers}}
<div>
<a href="/_/{{$user.Domain}}" class="recommend-card">
<img class="recommend-avatar" src="{{$user.Avatar}}" alt="avatar">
<div class="recommend-info">
<div class="recommend-name truncate">{{$user.Name}}</div>
<div class="recommend-tags">
{{if $user.TagList}}
{{range $index, $tag := $user.TagList}}
<span class="uk-label {{index $user.TagColors $index}}">{{$tag}}</span>
{{end}}
{{else}}
<span class="uk-text-muted">{{t $.LangCode "explore.noTags"}}</span>
{{end}}
</div>
</div>
</a>
</div>
{{end}}
{{else}}
<div class="uk-text-center uk-padding">{{t .LangCode "explore.noRecommend"}}</div>
{{end}}
</div>
</div>
<!-- 热门标签榜 -->
{{if .HotTags}}
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<h4 class="uk-card-title uk-margin-remove-bottom">{{t .LangCode "explore.hotTags"}}</h4>
<div class="uk-margin-small-top uk-flex uk-flex-wrap uk-flex-middle" style="gap: 6px;">
{{range $i, $tag := .HotTags}}
<a class="uk-label {{if eq (mod $i 5) 0}}uk-label-success{{else if eq (mod $i 5) 1}}uk-label-warning{{else if eq (mod $i 5) 2}}uk-label-danger{{else if eq (mod $i 5) 3}}uk-label-primary{{else}}uk-label-secondary{{end}}"
href="/explore?q={{urlquery $tag}}" style="text-decoration:none;">#{{$tag}}</a>
{{end}}
</div>
</div>
{{end}}
<form method="get" action="/explore" class="uk-margin uk-margin-bottom">
<div class="uk-grid-small" uk-grid>
<div class="uk-width-expand">
<div class="uk-inline uk-width-1-1">
<button class="uk-form-icon uk-form-icon-flip" uk-icon="icon: search" type="submit"></button>
<input class="uk-input" type="text" name="q" placeholder="{{t .LangCode "explore.search"}}" value="{{.Keyword}}">
</div>
</div>
<div class="uk-width-auto">
<select name="sort" class="uk-select" onchange="this.form.submit()">
<option value="latest" {{if eq .Sort "latest"}}selected{{end}}>{{t .LangCode "explore.sortLatest"}}</option>
<option value="oldest" {{if eq .Sort "oldest"}}selected{{end}}>{{t .LangCode "explore.sortOldest"}}</option>
</select>
</div>
</div>
</form>
{{if .Users}}
<div class="uk-child-width-1-1 uk-child-width-1-2@s uk-grid-match" uk-grid>
{{range .Users}}
{{$user := .}}
<div>
<div class="uk-card uk-card-default uk-card-hover uk-card-body">
<div class="uk-flex uk-flex-top">
<div>
<img class="explore-avatar" src="{{$user.Avatar}}" alt="avatar">
</div>
<div class="explore-info">
<div class="explore-name truncate">
<a href="/_/{{$user.Domain}}" title="{{$user.Name}}">{{$user.Name}}</a>
</div>
<div class="explore-intro" title="{{$user.Intro}}">
{{if $user.Intro}}{{$user.Intro}}{{else}}{{t $.LangCode "explore.lazy"}}{{end}}
</div>
<div class="explore-tags">
{{if $user.TagList}}
{{range $index, $tag := $user.TagList}}
<span class="uk-label {{index $user.TagColors $index}}" title="{{$tag}}">{{$tag}}</span>
{{end}}
{{else}}
<span class="uk-text-muted">{{t $.LangCode "explore.noTags"}}</span>
{{end}}
</div>
</div>
</div>
</div>
</div>
{{end}}
</div>
<ul class="uk-pagination uk-flex-center uk-margin-top" uk-margin>
{{if gt .Page 1}}
<li><a href="?{{if .Keyword}}q={{.Keyword}}&{{end}}sort={{.Sort}}&page={{.PrevPage}}"><span uk-pagination-previous></span> {{t .LangCode "explore.prevPage"}}</a></li>
{{else}}
<li class="uk-disabled"><span><span uk-pagination-previous></span> {{t .LangCode "explore.prevPage"}}</span></li>
{{end}}
<li class="uk-active"><span>{{.Page}} / {{.TotalPages}}</span></li>
{{if lt .Page .TotalPages}}
<li><a href="?{{if .Keyword}}q={{.Keyword}}&{{end}}sort={{.Sort}}&page={{.NextPage}}">{{t .LangCode "explore.nextPage"}} <span uk-pagination-next></span></a></li>
{{else}}
<li class="uk-disabled"><span>{{t .LangCode "explore.nextPage"}} <span uk-pagination-next></span></span></li>
{{end}}
</ul>
{{else}}
<div class="uk-alert uk-alert-warning">{{t .LangCode "explore.noResult"}}</div>
{{end}}
</div>
<div class="explore-panel" data-explore-panel="votes" hidden>
<div class="uk-card uk-card-default uk-card-body uk-margin-bottom">
<div class="explore-vote-toolbar">
<div>
<h3 class="uk-card-title uk-margin-remove">{{t .LangCode "explore.votesTitle"}}</h3>
<p class="uk-text-meta uk-text-small">{{t .LangCode "explore.votesDesc"}}</p>
</div>
<button type="button" class="vote-random-btn" id="random-vote-btn"><span uk-icon="icon: refresh; ratio: .8"></span> {{t .LangCode "explore.randomVote"}}</button>
</div>
{{if .HotVotes}}
<h4 class="uk-heading-bullet uk-margin-small-bottom">{{t .LangCode "explore.hotVotes"}}</h4>
<div class="hot-votes-list uk-margin-small-bottom">
{{range $vote := .HotVotes}}
<a href="/vote/{{$vote.LinkToken}}" class="hot-vote-item">
<div class="hot-vote-content">
<span class="hot-vote-title" title="{{$vote.Title}}">{{$vote.Title}}</span>
<span class="hot-vote-meta">
{{if eq $vote.Type "single"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeSingle"}}</span>{{end}}
{{if eq $vote.Type "multiple"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeMultiple"}}</span>{{end}}
{{if eq $vote.Type "rating"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRating"}}</span>{{end}}
{{if eq $vote.Type "rank"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRank"}}</span>{{end}}
{{if eq $vote.Type "form"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeForm"}}</span>{{end}}
</span>
</div>
<span class="hot-vote-action">{{t $.LangCode "vote.page.goVote"}} <span uk-icon="icon: arrow-right; ratio: .8"></span></span>
</a>
{{end}}
</div>
{{end}}
<h4 class="uk-heading-bullet uk-margin-medium-top uk-margin-small-bottom">{{t .LangCode "explore.allVotes"}}</h4>
{{if .AllVotes}}
<div class="all-votes-list" id="all-votes-list">
{{range $vote := .AllVotes}}
<a href="/vote/{{$vote.LinkToken}}" class="all-vote-item">
<div class="hot-vote-content">
<span class="hot-vote-title" title="{{$vote.Title}}">{{$vote.Title}}</span>
<span class="hot-vote-meta">
{{if eq $vote.Type "single"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeSingle"}}</span>{{end}}
{{if eq $vote.Type "multiple"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeMultiple"}}</span>{{end}}
{{if eq $vote.Type "rating"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRating"}}</span>{{end}}
{{if eq $vote.Type "rank"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeRank"}}</span>{{end}}
{{if eq $vote.Type "form"}}<span class="uk-label uk-label-primary">{{t $.LangCode "vote.typeForm"}}</span>{{end}}
<span class="uk-text-meta">{{$vote.CreatedAt.Format "2006-01-02"}}</span>
</span>
</div>
<span class="hot-vote-action">{{t $.LangCode "vote.page.goVote"}} <span uk-icon="icon: arrow-right; ratio: .8"></span></span>
</a>
{{end}}
</div>
{{else}}
<div class="uk-alert uk-alert-primary uk-margin-remove">{{t .LangCode "explore.noVotes"}}</div>
{{end}}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const recommendContainer = document.getElementById('recommend-list');
const refreshBtn = document.getElementById('refresh-recommend');
const tabs = document.querySelectorAll('[data-explore-tab]');
const panels = document.querySelectorAll('[data-explore-panel]');
function showExplorePanel(name) {
tabs.forEach(function(tab) {
const active = tab.dataset.exploreTab === name;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
panels.forEach(function(panel) {
panel.hidden = panel.dataset.explorePanel !== name;
});
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, window.location.pathname + (name === 'votes' ? '#votes' : ''));
}
}
tabs.forEach(function(tab) {
tab.addEventListener('click', function() { showExplorePanel(tab.dataset.exploreTab); });
});
if (window.location.hash === '#votes') showExplorePanel('votes');
const randomVoteBtn = document.getElementById('random-vote-btn');
if (randomVoteBtn) {
randomVoteBtn.addEventListener('click', function() {
const votes = Array.from(document.querySelectorAll('#all-votes-list .all-vote-item'));
if (!votes.length) return;
const target = votes[Math.floor(Math.random() * votes.length)];
randomVoteBtn.classList.add('is-spinning');
target.style.outline = '2px solid #1e87f0';
target.style.outlineOffset = '2px';
window.setTimeout(function() {
window.location.href = target.href;
}, 350);
});
}
// 递归提取用户数组,无视包裹层数
function extractUsers(obj) {
if (Array.isArray(obj)) return obj;
if (!obj || typeof obj !== 'object') return [];
// 单个用户对象
if (obj.domain || obj.id) {
return [obj];
}
// 尝试从 data 中递归
if (obj.data) {
return extractUsers(obj.data);
}
// 遍历所有值
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const val = obj[key];
if (Array.isArray(val)) {
return val;
} else if (val && typeof val === 'object') {
const result = extractUsers(val);
if (result.length > 0) return result;
}
}
}
return [];
}
function escapeHTML(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 仅允许 http(s) 头像地址,阻断 javascript: 等伪协议。
function safeAvatarURL(url) {
if (typeof url !== 'string') return '';
if (/^https?:\/\//i.test(url)) return url;
return '';
}
function loadRandomRecommend() {
recommendContainer.innerHTML = '<div class="uk-text-center uk-padding"><div uk-spinner></div> {{t .LangCode "explore.loading"}}</div>';
fetch('/api/v1/explore/random')
.then(response => {
if (!response.ok) throw new Error(`HTTP {{t .LangCode "explore.loadFailed"}} ${response.status}`);
return response.json();
})
.then(resp => {
const users = extractUsers(resp);
if (users.length === 0) {
recommendContainer.innerHTML = '<div class="uk-text-center uk-padding">{{t .LangCode "explore.noRecommend"}}</div>';
return;
}
let html = '';
users.forEach(user => {
let tagsHtml = '';
if (user.tag_list && user.tag_list.length) {
user.tag_list.forEach((tag, idx) => {
const colorClass = (user.tag_color && user.tag_color[idx]) || 'uk-label-default';
tagsHtml += `<span class="uk-label ${escapeHTML(colorClass)}" title="${escapeHTML(tag)}">${escapeHTML(tag)}</span>`;
});
} else {
tagsHtml = '<span class="uk-text-muted">{{t .LangCode "explore.noTags"}}</span>';
}
html += `
<div>
<a href="/_/${escapeHTML(user.domain)}" class="recommend-card">
<img class="recommend-avatar" src="${safeAvatarURL(user.avatar)}" alt="avatar">
<div class="recommend-info">
<div class="recommend-name truncate">${escapeHTML(user.name)}</div>
<div class="recommend-tags">${tagsHtml}</div>
</div>
</a>
</div>
`;
});
recommendContainer.innerHTML = html;
if (window.UIkit && UIkit.grid) {
UIkit.grid(recommendContainer);
}
})
.catch(error => {
console.error('Failed to load random recommends:', error);
recommendContainer.innerHTML = '<div class="uk-alert uk-alert-danger">加载失败:' + escapeHTML(error.message) + '</div>';
});
}
// 初始推荐已由服务端渲染,只有"换一批"按钮触发 AJAX
refreshBtn.addEventListener('click', function() {
// 重启旋转动画(先移再触发 reflow 再加,确保每次点击都播放)
refreshBtn.classList.remove('is-spinning');
void refreshBtn.offsetWidth;
refreshBtn.classList.add('is-spinning');
loadRandomRecommend();
});
});
</script>
{{template "base/footer" .}}