diff --git a/assets/css/routes/forum.css b/assets/css/routes/forum.css index 1ec38f28..d5d155fd 100644 --- a/assets/css/routes/forum.css +++ b/assets/css/routes/forum.css @@ -181,6 +181,7 @@ .forum-posts.detail .avatar-img{--this-size:38px} .lists-imgs.count-3{--img-count:3} .lists-imgs.count-2,.lists-imgs.count-4{--img-count:2} +} @media(max-width:480px){ #forum .jio-forum__wrap{width:calc(100% - 24px)} #forum h1{font-size:23px} @@ -282,3 +283,88 @@ body.dark-theme .panel.panel-plate,body.dark-theme .plate-lists .plate-item,body .forum-header .count-mates > item + item{padding-left:15px;margin-left:15px} } body.dark-theme .forum-header,body.dark-theme .forum-header .header-content{background:var(--main-bg-color)} + +/* ── 版块 / 分类页面 ──────────────────────────────────────────── */ +.forum-listing .jio-forum__breadcrumbs{margin:0 0 12px} +.forum-listing .jio-forum__list-main{min-width:0} +.plate-tab{padding:0} +.plate-tab .affix-header-sm{padding:0 15px;background:var(--main-bg-color);border-radius:var(--main-radius) var(--main-radius) 0 0} +.plate-tab .affix-header-sm.affix,.plate-tab .affix-header-sm.affix-bottom{padding:8px 25px} +.plate-tab .tab-nav-theme{margin:0;border-bottom:1px solid var(--main-border-color)} +.plate-tab .tab-nav-theme li a{font-size:16px;color:var(--muted-2-color);display:block;padding:12px 14px} +.plate-tab .tab-nav-theme li.active a{font-weight:bold;color:var(--focus-color)} +.plate-tab .tab-content{padding:10px 15px} +.plate-tab .tab-content > .tab-pane{display:none} +.plate-tab .tab-content > .tab-pane.active{display:block} +.plate-tab .forum-tab-pane{min-height:120px} +.forum-header.blur-header{overflow:hidden;color:#fff} +.forum-header.blur-header .header-content{background:transparent;color:#fff;padding:28px 30px 22px} +.forum-header.blur-header .forum-title,.forum-header.blur-header .forum-title a{color:#fff} +.forum-header.blur-header .desc,.forum-header.blur-header .px12-sm{color:rgba(255,255,255,.86)} +.forum-header .abs-blur-bg img{width:100%;height:100%;object-fit:cover} +.forum-header .forum-mask{position:absolute;inset:0;background:rgba(76,80,87,.6)} +.forum-header.blur-header .plate-thumb{width:90px;height:90px;flex-shrink:0} +.forum-header.blur-header .plate-thumb > img{border-radius:var(--main-radius)} +.forum-header .moderator-btns{display:flex;flex-wrap:wrap;align-items:center;gap:6px} +.forum-header .moderator-btns .avatar-mini{--this-size:22px;margin-right:4px} +.forum-header .moderator-btns .avatar-mini img{border-radius:100px} +.forum-header .moderator-btns .but{font-size:12px;padding:3px 10px} + +/* ── 帖子详情页 ───────────────────────────────────────────────── */ +.forum-post .single-wrap{width:100%} +.forum-single-cover{border-radius:var(--main-radius);overflow:hidden} +.forum-article{padding:20px 26px} +.forum-article .article-header{margin-bottom:15px;padding-bottom:12px;margin-top:5px;border-bottom:1px solid var(--main-border-color)} +.forum-article .article-title{font-size:24px;line-height:1.4;margin:0 0 10px;font-weight:600} +.forum-article .article-title a{color:var(--key-color)} +.forum-article-meta,.forum-article-meta a{color:var(--muted-2-color);font-size:13px} +.forum-article-meta .meta-left{display:flex;align-items:center;gap:8px} +.forum-article-meta .meta-right{display:flex;align-items:center} +.forum-article-meta .item{margin-left:18px;display:inline-flex;align-items:center;color:var(--muted-2-color)} +.forum-article .wp-posts-content{font-size:15px;line-height:1.9;overflow-wrap:anywhere} +.forum-article .article-tags{display:flex;flex-wrap:wrap;gap:6px} +.forum-article .article-tags .but{font-size:12px;padding:4px 10px} +.footer-actions{margin:18px -10px 10px;padding-top:14px;border-top:1px solid var(--main-border-color)} +.footer-actions .item{display:flex;align-items:center;padding:4px 8px;margin:0 4px;color:var(--muted-color);border:0;background:none;cursor:pointer;border-radius:4px} +.footer-actions .item:hover{background:var(--muted-border-color)} +.footer-actions .item > i{font-size:18px;margin-right:5px} +.footer-actions .item text{font-size:12px} +.single-footer .but{font-size:12px;padding:4px 12px} +.vote-box.forum-vote{padding:20px 0} +.vote-box.forum-vote h2{font-size:17px} +.vote-item{position:relative;overflow:hidden;padding:8px 3.5em 8px 15px;margin-bottom:6px;border-radius:50px;background:var(--muted-border-color);color:var(--muted-color);display:block;cursor:pointer;text-overflow:ellipsis;white-space:nowrap} +.vote-item input{margin-right:8px} +.vote-progress{position:absolute;left:0;top:0;bottom:0;background:rgba(130,130,130,.15);transition:width .6s,background .2s;border-radius:50px;width:0;z-index:-1} +.vote-item .vote-count{position:absolute;right:14px;font-size:.9em;opacity:.8} +.vote-box.forum-vote button{display:block;margin:16px auto 0} + +/* ── 发帖 / 编辑页 ────────────────────────────────────────────── */ +.forum-edit .content-layout{margin-right:calc(var(--sidebar-width,311px) + 15px)} +.forum-edit .sidebar{float:left;width:var(--sidebar-width,311px);margin-left:calc(0px - var(--sidebar-width,311px))} +.forum-edit .editor-main-box{padding:20px 26px} +.forum-edit .edit-posts-featured{display:flex;align-items:center;gap:12px} +.forum-edit .forum-cover-preview{width:180px;height:101px;border-radius:var(--main-radius);overflow:hidden;background:var(--muted-border-color);display:flex;align-items:center;justify-content:center;color:var(--muted-3-color)} +.forum-edit .forum-cover-preview img{width:100%;height:100%;object-fit:cover} +.forum-edit .edit-posts-title{position:relative;margin:10px 0 14px} +.forum-edit .line-form-input{width:100%;border:0;border-bottom:1px solid var(--main-border-color);background:transparent;font-size:22px;font-weight:600;padding:8px 0;resize:none;overflow:hidden;outline:none;color:var(--key-color)} +.forum-edit .line-form-input:focus{border-bottom-color:var(--focus-color)} +.forum-edit .line-form-line{display:none} +.forum-edit .sidebar .zib-widget{padding:0} +.forum-edit .drop-btn{padding:12px 15px;cursor:pointer} +.forum-edit .drop-select .dropdown-menu{width:100%;left:0;right:0} +.forum-edit .select-drop-box .but{display:block;width:100%;text-align:left;margin-bottom:4px;border-radius:4px} +.forum-edit .select-drop-box .but.active{background:var(--focus-color);color:#fff} +.forum-edit .type-drop{padding:12px 15px} +.forum-edit .type-drop .but.active{--this-bg:var(--focus-color);--this-color:#fff} +.forum-edit .forum-type-vote .form-control{width:100%;margin-bottom:6px} +.forum-edit .jio-forum__editor-tools .but{font-size:13px} +@media (max-width:800px){ + .forum-edit .content-layout{margin-right:0} + .forum-edit .sidebar{width:100%;margin-left:0;margin-top:16px} + .forum-edit .editor-main-box{padding:15px} + .forum-article{padding:15px} + .forum-article .article-title{font-size:20px} + .forum-header.blur-header .header-content{padding:20px 15px} + .forum-header.blur-header .plate-thumb{width:64px;height:64px} +} +body.dark-theme .forum-header.blur-header .forum-mask{background:rgba(20,22,26,.72)} diff --git a/assets/js/forum-runtime.js b/assets/js/forum-runtime.js index 37b33b26..72d06722 100644 --- a/assets/js/forum-runtime.js +++ b/assets/js/forum-runtime.js @@ -95,6 +95,93 @@ typeSelect?.addEventListener('change', syncType) syncType() + // 发帖页:下拉式设置项(阅读权限 / 版块 / 话题 / 标签 / 类型)统一走隐藏输入。 + const syncHidden = (name, value) => { + root.querySelectorAll(`input[type="hidden"][name="${name}"]`).forEach((input) => { input.value = value }) + } + const syncTypeBox = () => { + const box = root.querySelector('.forum-type-vote') + const current = root.querySelector('input[type="hidden"][name="type"]')?.value || 'normal' + if (box) box.hidden = current !== 'vote' + } + root.addEventListener('click', (event) => { + const type = event.target.closest('[data-forum-type-set]') + if (type) { + root.querySelectorAll('[data-forum-type-set]').forEach((item) => item.classList.toggle('active', item === type)) + syncHidden('type', type.dataset.forumTypeSet) + syncTypeBox() + return + } + const view = event.target.closest('[data-forum-view]') + if (view) { + root.querySelectorAll('[data-forum-view]').forEach((item) => item.classList.toggle('active', item === view)) + syncHidden('view_limit', view.dataset.forumView) + const label = root.querySelector('span[name="view_limit"]') + if (label) label.textContent = view.textContent.trim() + return + } + const plate = event.target.closest('[data-forum-plate]') + if (plate) { + root.querySelectorAll('[data-forum-plate]').forEach((item) => item.classList.toggle('active', item === plate)) + syncHidden('plate_id', plate.dataset.forumPlate) + const label = root.querySelector('span[name="plate_id"]') + if (label) label.textContent = plate.textContent.trim() + return + } + const status = event.target.closest('[data-forum-status]') + if (status) { + root.querySelectorAll('[data-forum-status]').forEach((item) => item.classList.toggle('active', item === status)) + syncHidden('post_status', status.dataset.forumStatus) + return + } + const pick = event.target.closest('[data-forum-cover-pick]') + if (pick) { + root.querySelector('[data-forum-cover-file]')?.click() + return + } + const draft = event.target.closest('[data-forum-draft]') + if (draft) { + const form = draft.closest('[data-forum-form]') + if (form) { + syncHidden('post_status', 'draft') + form.requestSubmit() + } + } + }) + root.addEventListener('change', (event) => { + const term = event.target.closest('[data-forum-topics],[data-forum-tags]') + if (term) { + const field = term.matches('[data-forum-topics]') ? 'topics' : 'tags' + const values = [...root.querySelectorAll(`[data-forum-${field}]:checked`)].map((input) => input.value) + syncHidden(field, values.join(',')) + term.closest('label')?.classList.toggle('active', term.checked) + const label = root.querySelector(`[data-forum-${field}-label]`) + if (label) label.textContent = values.length ? values.slice(0, 2).join('、') : '请选择' + } + }) + root.addEventListener('change', async (event) => { + const cover = event.target.closest('[data-forum-cover-file]') + if (!cover?.files?.[0]) return + const data = new FormData() + data.append('file', cover.files[0]) + try { + const response = await fetch(config.api + 'upload/image', { method: 'POST', credentials: 'same-origin', headers: { 'X-WP-Nonce': config.nonce }, body: data }) + const payload = await response.json() + if (!response.ok || !payload.success) throw new Error(payload.message || '上传失败') + const preview = root.querySelector('.forum-cover-preview') + if (preview) { + preview.classList.remove('is-empty') + preview.innerHTML = '' + } + const hidden = root.querySelector('input[name="featured_image_id"]') + if (hidden) hidden.value = payload.data.id || '' + } catch (error) { + message(cover.closest('.edit-posts-featured') || root, error.message) + } finally { + cover.value = '' + } + }) + const insertMedia = (kind, url, name) => { const element = document.createElement(kind === 'image' ? 'img' : kind === 'video' ? 'video' : 'a') if (kind === 'resource') { element.href = url; element.textContent = name || '下载附件' } diff --git a/src/Admin/Schema/SchemaForum.php b/src/Admin/Schema/SchemaForum.php index 476ab0da..c12b8a00 100644 --- a/src/Admin/Schema/SchemaForum.php +++ b/src/Admin/Schema/SchemaForum.php @@ -53,6 +53,24 @@ final class SchemaForum { self::field('jio_forum_home_follow_plate', '版块栏目显示已关注版块', 'switcher', true), self::field('jio_forum_home_recommend_plate', '关注栏目显示版块推荐', 'switcher', true), self::field('jio_forum_show_plate_cover', '显示版块封面', 'switcher', true), + self::field('jio_forum_plate_tab_active', '版块页默认栏目', 'number', 1, ['min' => 1, 'max' => 20, 'description' => '打开版块页面时默认显示第几个栏目']), + self::field('jio_forum_plate_tab_items', '版块页栏目', 'repeater', self::default_plate_tabs(), [ + 'addText' => '添加栏目', + 'description' => '版块页顶部的栏目条;留空则使用「最新发布 / 最近回复 / 热门 / 精华」默认栏目', + 'fields' => [ + self::field('title', '栏目标题', 'input', ''), + self::field('orderby', '排序方式', 'select', 'date', ['options' => self::options([ + 'date' => '发布时间', + 'modified' => '修改时间', + 'last_reply' => '最新回复', + 'views' => '阅读量', + 'score' => '评分', + 'comment_count' => '回复数量', + ])]), + self::field('filter', '内容筛选', 'select', '', ['options' => self::options(['' => '全部', 'essence' => '精华'])]), + self::field('style', '列表样式', 'radio', 'detail', ['options' => self::options(['detail' => '详细内容', 'mini' => '简约风格'])]), + ], + ]), self::field('jio_forum_search_enabled', '搜索包含帖子', 'switcher', true), ]), 'publishing' => self::section('publishing', '发布与审核', [ @@ -95,6 +113,16 @@ final class SchemaForum { ]]; } + /** Initial plate-page tab rows; the listing falls back to these when unset. */ + private static function default_plate_tabs(): array { + return [ + ['title' => '最新发布', 'orderby' => 'date', 'filter' => '', 'style' => 'detail'], + ['title' => '最近回复', 'orderby' => 'last_reply', 'filter' => '', 'style' => 'detail'], + ['title' => '热门', 'orderby' => 'views', 'filter' => '', 'style' => 'detail'], + ['title' => '精华', 'orderby' => 'modified', 'filter' => 'essence', 'style' => 'detail'], + ]; + } + private static function section(string $key, string $title, array $fields): array { return ['key' => $key, 'title' => $title, 'version' => '1.0.0', 'option_key' => 'jio_settings', 'groups' => [['key' => $key, 'fields' => $fields]]]; } diff --git a/src/Forum/Cards.php b/src/Forum/Cards.php index 75cd6021..30a8b7f2 100644 --- a/src/Forum/Cards.php +++ b/src/Forum/Cards.php @@ -73,8 +73,61 @@ final class Cards { return '

' . $badges . esc_html($post->post_title) . '

'; } - public static function plate_item(\WP_Post $plate): string { - $url = get_permalink($plate); + /** Plate page header: blurred cover, title, stats and moderators (reference layout). */ + public static function plate_header(\WP_Post $plate): string { + $id = $plate->ID; + PlateStats::prime([$id]); + $stats = PlateStats::get($id); + $followers = (int) get_post_meta($id, '_jio_forum_follow_count', true); + $plate_name = (string) jio_get_setting('jio_forum_plate_name', '版块') ?: '版块'; + $thumb = self::thumbnail($plate); + $cats = ''; + $terms = wp_get_post_terms($id, Core::CATEGORY); + foreach (is_array($terms) ? $terms : [] as $term) { + $cats .= '' . esc_html($term->name) . ''; + } + $meta = '
' . esc_html(sprintf('%s %s', $plate_name, jio_cut_count($stats['posts']))) . '' + . '' . esc_html(sprintf('今日 %s', jio_cut_count($stats['today']))) . '' + . ($followers > 0 + ? '' . esc_html(sprintf('关注 %s', jio_cut_count($followers))) . '' + : '') . '
'; + $excerpt = wp_trim_words(wp_strip_all_tags($plate->post_excerpt ?: $plate->post_content), 40); + $html = '
'; + $html .= '
' . $thumb . '
'; + if (get_post_meta($id, '_jio_forum_hot', true)) { + $html .= '热门'; + } + $html .= '
'; + if ($cats !== '') $html .= '
' . $cats . '
'; + $html .= '

' . esc_html($plate->post_title) . '

'; + $html .= $meta; + if ($excerpt !== '') $html .= '
' . esc_html($excerpt) . '
'; + $html .= '
' . $thumb . '
'; + $html .= self::plate_moderator_btns($plate); + return $html . '
'; + } + + /** Plate moderator avatars row under the header. */ + private static function plate_moderator_btns(\WP_Post $plate): string { + $ids = array_values(array_unique(array_filter(array_merge( + [(int) $plate->post_author], + array_map('absint', (array) get_post_meta($plate->ID, '_jio_forum_moderators', true)) + )))); + if (!$ids) return ''; + $btns = ''; + foreach ($ids as $user_id) { + $user = get_userdata($user_id); + if (!$user) continue; + $btns .= '' . get_avatar($user_id, 40) . '' . esc_html($user->display_name) . ''; + } + $html = '
' . $btns; + if (is_user_logged_in() && !Access::is_moderator(get_current_user_id(), $plate->ID) && jio_get_setting('jio_forum_moderator_apply', true)) { + $html .= ''; + } + return $html . '
'; + } + + public static function plate_item(\WP_Post $plate): string { $url = get_permalink($plate); $counts = PlateStats::get($plate->ID); $today = $counts['today'] > 0 ? '' . esc_html(sprintf('今日:%s', jio_cut_count($counts['today']))) . '' : ''; $excerpt = wp_trim_words(wp_strip_all_tags($plate->post_excerpt ?: $plate->post_content), 30); diff --git a/src/Forum/EditView.php b/src/Forum/EditView.php new file mode 100644 index 00000000..c64709b5 --- /dev/null +++ b/src/Forum/EditView.php @@ -0,0 +1,167 @@ + 0 ? get_post($edit_id) : null; + if ($edit_id > 0 && (!$post instanceof \WP_Post || !Access::can_manage_post(get_current_user_id(), $edit_id))) { + status_header(403); + echo '
' . jio_get_null('抱歉!您暂无编辑此内容的权限', 60, 'null-post.svg') . '
'; + get_footer(); + return; + } + $user_id = get_current_user_id(); + $type = $post ? (string) get_post_meta($post->ID, '_jio_forum_type', true) : 'normal'; + $plate_id = $post ? (int) get_post_meta($post->ID, '_jio_forum_plate_id', true) : absint(is_scalar($_GET['plate'] ?? 0) ? $_GET['plate'] : 0); + $view_limit = $post ? (string) get_post_meta($post->ID, '_jio_forum_view_limit', true) : 'all'; + $vote_options = $post ? (array) get_post_meta($post->ID, '_jio_forum_vote_options', true) : []; + $topics = $post ? wp_get_post_terms($post->ID, Core::TOPIC, ['fields' => 'names']) : []; + $tags = $post ? wp_get_post_terms($post->ID, Core::TAG, ['fields' => 'names']) : []; + + wp_enqueue_editor(); + echo '
'; + echo '
'; + if ($user_id <= 0) { + echo jio_get_null('请先登录后再发布', 40, 'null-user.svg') . '

登录

'; + echo '
'; + self::close(); + return; + } + // Cover + $cover = $post ? get_the_post_thumbnail_url($post, 'large') : ''; + echo ''; + // Title + echo '
'; + // Editor + wp_editor($post ? $post->post_content : '', 'jio_forum_content', [ + 'textarea_name' => 'content', + 'textarea_rows' => 20, + 'editor_height' => wp_is_mobile() ? 460 : 486, + 'media_buttons' => false, + 'quicktags' => false, + 'default_editor' => 'tinymce', + 'tinymce' => ['placeholder' => (string) jio_get_setting('jio_forum_content_placeholder', '请输入内容')], + ]); + echo '
'; + foreach (['image' => ['插入图片', 'fa-image'], 'video' => ['插入视频', 'fa-video'], 'resource' => ['插入附件', 'fa-paperclip']] as $kind => [$label, $icon]) { + $setting = 'jio_forum_upload_' . ($kind === 'resource' ? 'file' : $kind); + if (jio_get_setting($setting, $kind === 'image')) { + echo ''; + } + } + echo '
'; + self::hidden_fields($post, $type, $view_limit); + echo '
'; // editor-main-box + + // Sidebar: submit, status, access, plate, topic, tag, type, vote + echo ''; + self::close(); + } + + private static function hidden_fields(?\WP_Post $post, string $type, string $view_limit): void { + echo ''; + echo ''; + echo '

'; + } + + private static function submit_box(?\WP_Post $post): string { + $last = $post ? get_the_modified_time('Y-m-d H:i:s', $post->ID) : ''; + $html = '
'; + if (!$post || !in_array($post->post_status, ['publish', 'pending'], true)) { + $html .= ''; + } + $html .= ''; + $html .= '
'; + if ($post && $last !== '') { + $html .= '
编辑内容最后更新:' . esc_html($last) . '
'; + } + return $html . '
'; + } + + private static function status_box(?\WP_Post $post): string { + if (!$post instanceof \WP_Post) return ''; + $status = in_array($post->post_status, ['pending', 'draft', 'publish'], true) ? $post->post_status : 'pending'; + $list = ''; + foreach (['pending' => '待审核', 'draft' => '草稿', 'publish' => '发布'] as $key => $label) { + $list .= '' . $label . ''; + } + return '
设置状态
' . $list . '
'; + } + + private static function allow_view_box(string $current): string { + $labels = ['all' => '公开', 'login' => '登录后可查看', 'follow' => '关注版块后可查看', 'points' => '支付积分查看', 'pay' => '付费查看']; + $options = ''; + foreach ($labels as $key => $label) { + $options .= '
' . esc_html($label) . '
'; + } + return ''; + } + + private static function plate_box(int $current): string { + $plates = get_posts(['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => 200, 'orderby' => 'title', 'order' => 'ASC']); + $options = ''; + foreach ($plates as $plate) { + $options .= '
' . esc_html($plate->post_title) . '
'; + } + $name = $current > 0 ? get_the_title($current) : '请选择'; + return ''; + } + + /** @param string[] $selected */ + private static function term_box(string $field, string $label, string $taxonomy, array $selected, string $icon): string { + $terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false, 'number' => 200, 'orderby' => 'count', 'order' => 'DESC']); + $selected = array_values(array_filter(array_map('strval', $selected))); + $options = ''; + foreach (is_array($terms) ? $terms : [] as $term) { + $active = in_array($term->name, $selected, true); + $options .= ''; + } + return ''; + } + + private static function type_box(string $current): string { + $options = []; + $options['normal'] = '标准'; + if (jio_get_setting('jio_forum_question_enabled', true)) $options['question'] = '提问'; + if (jio_get_setting('jio_forum_vote_enabled', true)) $options['vote'] = '投票'; + if (count($options) < 2) return ''; + $list = ''; + foreach ($options as $key => $label) { + $list .= '' . esc_html($label) . ''; + } + return '
发布类型
' . $list . '
'; + } + + /** @param string[] $options */ + private static function vote_box(array $options, ?\WP_Post $post): string { + $type = $post ? (string) get_post_meta($post->ID, '_jio_forum_type', true) : 'normal'; + return '
投票设置
' + . '' + . '' + . '' + . '
'; + } + + private static function close(): void { + echo '
'; + get_footer(); + } +} diff --git a/src/Forum/ListingView.php b/src/Forum/ListingView.php new file mode 100644 index 00000000..d56a38b6 --- /dev/null +++ b/src/Forum/ListingView.php @@ -0,0 +1,198 @@ +post_type !== Core::PLATE) { + self::term(); + return; + } + self::open($plate->post_title, 'plate', $plate); + \Jio\WidgetSystem\WidgetAreas::render('forum_plate_top_fluid'); + echo '
'; + echo Cards::plate_header($plate); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo self::tab_bar($plate); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
'; + self::close('plate'); + } + + /** Taxonomy archives (plate category / topic / tag). */ + public static function term(): void { + $term = get_queried_object(); + if (!$term instanceof \WP_Term) { + self::open('论坛', 'term', null); + self::close('term'); + return; + } + self::open($term->name, 'term', null, $term); + \Jio\WidgetSystem\WidgetAreas::render('forum_term_top_fluid'); + echo '
'; + echo self::tab_bar(null, $term); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
'; + self::close('term'); + } + + private static function open(string $title, string $context, ?\WP_Post $plate, ?\WP_Term $term = null): void { + echo '
'; + echo '
'; + echo '
'; + if ($plate) { + $plate_name = (string) jio_get_setting('jio_forum_plate_name', '版块') ?: '版块'; + echo ''; + } elseif ($term instanceof \WP_Term) { + echo '
' . esc_html($term->name) . '
'; + if ($term->description !== '') echo '

' . esc_html($term->description) . '

'; + echo '
'; + } + } + + private static function close(string $context): void { + echo '
'; + self::sidebar($context); + echo '
'; + \Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_bottom_fluid'); + echo '
'; + } + + private static function sidebar(string $context): void { + if (!jio_is_show_sidebar()) return; + echo ''; + } + + /** Plate tab bar + panes; the first tab renders server-side, the rest load by `?plate_tab=N`. */ + public static function tab_bar(?\WP_Post $plate = null, ?\WP_Term $term = null): string { + $tabs = self::tabs($plate); + if (!$tabs) return ''; + $active = self::active_index(count($tabs)); + $base = remove_query_arg(['plate_tab', 'fpage']); + if ($term instanceof \WP_Term) $base = get_term_link($term); + $nav = $panes = ''; + foreach ($tabs as $index => $tab) { + $id = 'plate-tab-' . $index; + $href = add_query_arg('plate_tab', $index, is_string($base) ? $base : Core::home_url()); + $nav .= '' . $tab['title'] . ''; + $panes .= '
'; + $panes .= $index === $active + ? self::pane($tab, $plate, $term, $index) + : '
' . HomeTabs::placeholder('posts', 3) . '
'; + $panes .= '
'; + } + return '
    ' . $nav . '
' . $panes . '
'; + } + + /** @return array> */ + public static function tabs(?\WP_Post $plate = null): array { + $tabs = []; + $configured = jio_get_setting('jio_forum_plate_tab_items', null); + if (is_array($configured) && $configured) { + foreach ($configured as $row) { + if (!is_array($row)) continue; + $tabs[] = [ + 'title' => wp_kses_post((string) ($row['title'] ?? '') ?: '栏目'), + 'orderby' => sanitize_key((string) ($row['orderby'] ?? 'date')), + 'filter' => sanitize_key((string) ($row['filter'] ?? '')), + 'style' => sanitize_key((string) ($row['style'] ?? 'detail')), + ]; + } + } else { + foreach ([ + ['最新发布', 'date', '', 'detail'], + ['最近回复', 'last_reply', '', 'detail'], + ['热门', 'views', '', 'detail'], + ['精华', 'modified', 'essence', 'detail'], + ] as [$title, $orderby, $filter, $style]) { + $tabs[] = ['title' => $title, 'orderby' => $orderby, 'filter' => $filter, 'style' => $style]; + } + } + $user_id = get_current_user_id(); + if ($plate instanceof \WP_Post && Access::is_moderator($user_id, $plate->ID)) { + $tabs[] = ['title' => '待审核', 'orderby' => 'date', 'filter' => '', 'style' => 'mini', 'status' => 'pending']; + $tabs[] = ['title' => '回收站', 'orderby' => 'date', 'filter' => '', 'style' => 'mini', 'status' => 'trash']; + } + return $tabs; + } + + public static function active_index(int $count): int { + $raw = $_GET['plate_tab'] ?? 0; + $index = (is_scalar($raw) ? absint($raw) : 0) ?: (int) jio_get_setting('jio_forum_plate_tab_active', 1); + return $index >= 1 && $index <= $count ? $index : 1; + } + + private static function pane(array $tab, ?\WP_Post $plate, ?\WP_Term $term, int $index): string { + $page = max(1, is_scalar($_GET['fpage'] ?? 1) ? absint($_GET['fpage'] ?? 1) : 1); + $args = [ + 'post_type' => Core::POST, + 'post_status' => $tab['status'] ?? 'publish', + 'posts_per_page' => max(1, min(50, (int) jio_get_setting('jio_forum_page_size', 20))), + 'paged' => $page, + 'jio_forum_list' => $tab['orderby'], + ]; + if ($plate instanceof \WP_Post) { + $args['meta_query'][] = ['key' => '_jio_forum_plate_id', 'value' => $plate->ID]; + $topping = self::topping($plate->ID, $tab); + if ($topping) { + $args['post__not_in'] = $topping; + } + } elseif ($term instanceof \WP_Term) { + $args['tax_query'][] = ['taxonomy' => $term->taxonomy, 'terms' => [$term->term_id]]; + } + if (($tab['filter'] ?? '') === 'essence') { + $args['meta_query'][] = ['key' => '_jio_forum_essence', 'value' => 1]; + } + $query = new \WP_Query($args); + $html = ''; + if ($plate instanceof \WP_Post && ($tab['status'] ?? '') === '') { + Cards::prime($topping_posts = self::topping_posts($plate->ID, $tab)); + foreach ($topping_posts as $post) { + $html .= Cards::post_mini($post, 'ajax-item', true); + } + } + if ($query->have_posts()) { + Cards::prime($query->posts); + foreach ($query->posts as $post) { + $html .= ($tab['style'] ?? 'detail') === 'detail' + ? Cards::post_detail($post, 'ajax-item') + : Cards::post_mini($post, 'ajax-item'); + } + } + $base = $plate instanceof \WP_Post ? get_permalink($plate) : ($term instanceof \WP_Term ? get_term_link($term) : Core::home_url()); + $next = add_query_arg(['plate_tab' => $index, 'fpage' => $page + 1], is_string($base) ? $base : Core::home_url()); + if ($page < (int) $query->max_num_pages) { + $html .= ''; + } + $html .= '
' . HomeTabs::placeholder('posts', 3) . '
'; + if ($html !== '' && trim(wp_strip_all_tags($html)) !== '') return $html; + $empty = jio_get_ajax_null('内容空空如也'); + if ($index === 1 && $page === 1 && $plate instanceof \WP_Post) { + $empty .= ''; + } + return $empty; + } + + /** @return int[] */ + private static function topping(int $plate_id, array $tab): array { + if (($tab['filter'] ?? '') === 'essence' || ($tab['status'] ?? '') !== '') return []; + return wp_list_pluck(self::topping_posts($plate_id, $tab), 'ID'); + } + + /** @return \WP_Post[] */ + private static function topping_posts(int $plate_id, array $tab): array { + return get_posts([ + 'post_type' => Core::POST, + 'post_status' => 'publish', + 'posts_per_page' => 5, + 'meta_query' => [ + ['key' => '_jio_forum_plate_id', 'value' => $plate_id], + ['key' => '_jio_forum_pinned', 'value' => 1], + ], + ]); + } +} diff --git a/src/Forum/PostView.php b/src/Forum/PostView.php new file mode 100644 index 00000000..7f9efe81 --- /dev/null +++ b/src/Forum/PostView.php @@ -0,0 +1,190 @@ +post_type !== Core::POST) { + return; + } + $id = (int) $post->ID; + $plate_id = (int) get_post_meta($id, '_jio_forum_plate_id', true); + $type = (string) get_post_meta($id, '_jio_forum_type', true); + $user_id = get_current_user_id(); + $can_view = Access::can_view($user_id, $id); + + echo '
'; + echo '
'; + \Jio\WidgetSystem\WidgetAreas::render('forum_post_top_fluid'); + do_action('bbs_posts_page_content_top'); + if ($can_view) echo self::cover($post); + echo self::breadcrumbs($post); + echo '
'; + echo '
' . self::title($post) . self::meta($post) . '
'; + echo self::content($post, $can_view); + echo self::footer($post); + echo '
'; + if ($can_view) { + self::comments($id); + } + do_action('bbs_posts_page_content_bottom'); + \Jio\WidgetSystem\WidgetAreas::render('forum_post_bottom_fluid'); + echo '
'; + if (jio_is_show_sidebar()) { + echo ''; + } + echo '
'; + } + + private static function breadcrumbs(\WP_Post $post): string { + $plate_id = (int) get_post_meta($post->ID, '_jio_forum_plate_id', true); + $icon = ' '; + $html = ''; + } + + private static function cover(\WP_Post $post): string { + if (!has_post_thumbnail($post)) return ''; + return '
' . get_the_post_thumbnail($post, 'large', ['class' => 'fit-cover']) . '
'; + } + + private static function title(\WP_Post $post): string { + return Cards::title($post, 'article-title', '', true); + } + + private static function meta(\WP_Post $post): string { + $author = (int) $post->post_author; + $name = function_exists('jio_get_user_name') ? jio_get_user_name(['id' => $author, 'class' => '', 'name_class' => 'focus-color']) : esc_html((string) get_the_author_meta('display_name', $author)); + $left = '
' . $name + . '' . esc_html(Cards::ago($post->post_date)) . '
'; + $right = '
' + . '' . esc_html(jio_cut_count((int) get_post_meta($post->ID, '_jio_forum_views', true))) . '' + . '' . esc_html(jio_cut_count((int) $post->comment_count)) . '' + . '
'; + return ''; + } + + private static function content(\WP_Post $post, bool $can_view): string { + $id = (int) $post->ID; + if (!$can_view) { + return '
' . self::locked($post) . '
'; + } + $html = '
' . apply_filters('the_content', $post->post_content) . '
'; + $type = (string) get_post_meta($id, '_jio_forum_type', true); + if ($type === 'vote') { + $html .= self::vote($post); + } + return $html; + } + + private static function locked(\WP_Post $post): string { + $id = (int) $post->ID; + $plate_id = (int) get_post_meta($id, '_jio_forum_plate_id', true); + $user_id = get_current_user_id(); + $html = jio_get_null('抱歉!您暂无查看此内容的权限', 10, 'null-post.svg'); + if ($user_id <= 0) { + return $html . '

登录后查看

'; + } + $plate_rule = (string) get_post_meta($plate_id, '_jio_forum_view_limit', true); + $post_rule = (string) get_post_meta($id, '_jio_forum_view_limit', true); + $action = ''; + if (in_array($plate_rule, ['points', 'pay'], true) && !Commerce::has_plate_entitlement($user_id, $plate_id)) { + $action = ''; + } elseif (($plate_rule === 'follow' || $post_rule === 'follow') && !Access::follows($user_id, $plate_id)) { + $action = Cards::follow_button($plate_id, 'but jb-blue padding-lg'); + } elseif (in_array($post_rule, ['points', 'pay'], true)) { + $action = ''; + } + return $html . ($action !== '' ? '

' . $action . '

' : ''); + } + + private static function vote(\WP_Post $post): string { + $id = (int) $post->ID; + $options = (array) get_post_meta($id, '_jio_forum_vote_options', true); + if (!$options) return ''; + $counts = (array) get_post_meta($id, '_jio_forum_vote_counts', true); + $total = (int) get_post_meta($id, '_jio_forum_vote_total', true); + $vote_type = (string) get_post_meta($id, '_jio_forum_vote_type', true); + $deadline = (int) get_post_meta($id, '_jio_forum_vote_deadline', true); + $expired = $deadline > 0 && current_time('timestamp') > $deadline; + $display = (string) jio_get_setting('jio_forum_vote_number_type', 'percentage'); + $voted = get_current_user_id() > 0 && get_user_meta(get_current_user_id(), '_jio_forum_vote_' . $id, true) !== ''; + $html = '

' . esc_html((string) get_post_meta($id, '_jio_forum_vote_title', true) ?: '参与投票') . '

'; + foreach ($options as $index => $option) { + $count = (int) ($counts[$index] ?? 0); + $percent = $total > 0 ? (int) round(100 * $count / $total) : 0; + $html .= ''; + } + $html .= '

'; + if ($voted) $html .= '

已投票

'; + elseif ($expired) $html .= '

投票已截止

'; + else $html .= ''; + return $html . '
'; + } + + private static function footer(\WP_Post $post): string { + $id = (int) $post->ID; + $user_id = get_current_user_id(); + $plate_id = (int) get_post_meta($id, '_jio_forum_plate_id', true); + $html = ''; + $html .= ''; + $html .= ''; + if (get_post_meta($id, '_jio_forum_type', true) === 'question' && get_post_meta($id, '_jio_forum_adopted_comment', true)) { + $html .= '

已采纳回复

'; + } + return $html; + } + + private static function comments(int $post_id): void { + echo '
'; + View::replies($post_id); + echo '
'; + } +} diff --git a/src/Forum/RestPosts.php b/src/Forum/RestPosts.php index 9ee1e6e9..104a9bb8 100644 --- a/src/Forum/RestPosts.php +++ b/src/Forum/RestPosts.php @@ -107,6 +107,13 @@ trait RestPosts { return self::fail('已有采纳回复后不能更改帖子类型'); } $status = Access::is_moderator($user_id, $plate_id) || !jio_get_setting('jio_forum_post_review', true) ? 'publish' : 'pending'; + // Authors may park their own post as a draft from the compose page. + $requested = sanitize_key((string) ($input['post_status'] ?? '')); + if ($requested === 'draft' && !($current && in_array($current->post_status, ['publish', 'pending'], true))) { + $status = 'draft'; + } elseif ($requested !== '' && $current && Access::is_moderator($user_id, $plate_id) && in_array($requested, ['publish', 'pending', 'draft'], true)) { + $status = $requested; + } $post_data = [ 'ID' => $id, 'post_type' => Core::POST, 'post_title' => $title, 'post_content' => $content, 'post_status' => $status, 'post_author' => $current ? (int) $current->post_author : $user_id, @@ -124,6 +131,12 @@ trait RestPosts { update_post_meta($id, '_jio_forum_type', $type); update_post_meta($id, '_jio_forum_view_limit', $view); update_post_meta($id, '_jio_forum_price', $price); + $cover_id = absint($input['featured_image_id'] ?? 0); + if ($cover_id > 0 && wp_attachment_is_image($cover_id)) { + set_post_thumbnail($id, $cover_id); + } elseif (array_key_exists('featured_image_id', $input)) { + delete_post_thumbnail($id); + } foreach (['vip1', 'vip2'] as $vip) { if (!isset($input['price_' . $vip])) continue; $vip_price = (string) $input['price_' . $vip]; diff --git a/src/Forum/View.php b/src/Forum/View.php index e769ad80..e18b78b7 100644 --- a/src/Forum/View.php +++ b/src/Forum/View.php @@ -44,67 +44,7 @@ final class View { } public static function listing(): void { - $obj = get_queried_object(); - $plate = $obj instanceof \WP_Post && $obj->post_type === Core::PLATE ? $obj : null; - $term = $obj instanceof \WP_Term ? $obj : null; - $title = $plate ? $plate->post_title : ($term ? $term->name : '论坛'); - $context = $plate ? 'plate' : 'term'; - self::open($title, $context); - \Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_top_fluid'); - echo '
'; - echo '

' . esc_html($title) . '

'; - if ($plate) { - echo '
' . wp_kses_post(wpautop($plate->post_excerpt ?: $plate->post_content)) . '
'; - echo '

' . (int) get_post_meta($plate->ID, '_jio_forum_follow_count', true) . ' 人关注

'; - echo Cards::follow_button($plate->ID, 'but c-blue'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - if (Access::is_moderator(get_current_user_id(), $plate->ID)) { - self::plate_editor($plate); - } - if (is_user_logged_in() && !Access::is_moderator(get_current_user_id(), $plate->ID) && jio_get_setting('jio_forum_moderator_apply', true)) { - self::action('apply', $plate->ID, '申请版主'); - } - if (Access::is_admin(get_current_user_id()) || (int) $plate->post_author === get_current_user_id()) { - self::applications($plate->ID); - } - } elseif ($term) { - echo '

' . esc_html($term->description) . '

'; - } - echo '
'; - self::tabs(); - if ($term && $term->taxonomy === Core::CATEGORY) { - self::plates(['tax_query' => [['taxonomy' => Core::CATEGORY, 'terms' => [$term->term_id]]]]); - } else { - $args = ['post_type' => Core::POST, 'post_status' => 'publish']; - if ($plate) { - $args['meta_query'] = [['key' => '_jio_forum_plate_id', 'value' => $plate->ID]]; - } elseif ($term) { - $args['tax_query'] = [['taxonomy' => $term->taxonomy, 'terms' => [$term->term_id]]]; - } - self::posts($args, $context); - if ($plate && Access::is_moderator(get_current_user_id(), $plate->ID)) { - echo '

待审核帖子

'; - $args['post_status'] = 'pending'; - self::posts($args, $context); - } - } - echo '
'; - self::sidebar($context); - echo '
'; - \Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_bottom_fluid'); - self::close(); - } - - /** Listing pages (plate / taxonomy) use a fixed, always-available tab set. */ - public static function tabs(): void { - $tabs = ['latest' => '最新', 'popular' => '热门', 'essence' => '精华', 'question' => '问答', 'vote' => '投票']; - $base_url = is_singular(Core::PLATE) ? get_permalink(get_queried_object_id()) : (is_tax([Core::CATEGORY, Core::TOPIC, Core::TAG]) ? get_term_link(get_queried_object()) : Core::home_url()); - echo ''; + ListingView::plate(); } public static function posts(array $args, string $context = 'home'): void { diff --git a/templates/pages/page-forum-edit.php b/templates/pages/page-forum-edit.php index 67e30329..8a88ccb5 100644 --- a/templates/pages/page-forum-edit.php +++ b/templates/pages/page-forum-edit.php @@ -1,57 +1,4 @@ 0 ? get_post($edit_id) : null; -if (!is_user_logged_in()) { - wp_safe_redirect(jio_get_login_url(Core::edit_url($edit_id))); - exit; -} -if ($edit_id > 0 && (!$post instanceof WP_Post || !Access::can_manage_post(get_current_user_id(), $edit_id))) { - status_header(403); - wp_die(esc_html__('无权编辑此帖子', 'jio')); -} - -wp_enqueue_editor(); - -$plate_id = $post ? (int) get_post_meta($post->ID, '_jio_forum_plate_id', true) : absint($_GET['plate'] ?? 0); -$vote_started = $post ? (int) get_post_meta($post->ID, '_jio_forum_vote_started', true) : 0; -$vote_deadline = $post ? (int) get_post_meta($post->ID, '_jio_forum_vote_deadline', true) : 0; -$vote_days = $vote_started && $vote_deadline ? (int) round(($vote_deadline - $vote_started) / DAY_IN_SECONDS) : 0; -$plates = get_posts(['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC']); -View::open($post ? '编辑帖子' : '发布帖子', 'edit'); -?> -
-

-
- - - - - post_content : '', 'jio_forum_content', ['textarea_name' => 'content', 'textarea_rows' => 16, 'media_buttons' => false, 'quicktags' => true, 'tinymce' => ['toolbar1' => 'formatselect,bold,italic,bullist,numlist,blockquote,link,unlink,alignleft,aligncenter,alignright,undo,redo', 'toolbar2' => 'forecolor,removeformat']]); ?> -
- '插入图片', 'video' => '插入视频', 'resource' => '插入附件'] as $kind => $label) : ?> - - - -
- - -
- - - - -
- - - - -

-
-
- +\Jio\Forum\EditView::render(); diff --git a/templates/pages/page-forum-post.php b/templates/pages/page-forum-post.php index ad8a052c..42624e70 100644 --- a/templates/pages/page-forum-post.php +++ b/templates/pages/page-forum-post.php @@ -1,85 +1,5 @@ ID; -$plate_id = (int) get_post_meta($id, '_jio_forum_plate_id', true); -$type = (string) get_post_meta($id, '_jio_forum_type', true); -$user_id = get_current_user_id(); -$can_view = Access::can_view($user_id, $id); -$can_manage = Access::can_manage_post($user_id, $id); -$can_moderate = Access::is_moderator($user_id, $plate_id); -View::open($post->post_title, 'post'); -\Jio\WidgetSystem\WidgetAreas::render('forum_post_top_fluid'); -?> -
-
-
-
post_author, 40); ?>post_author)); ?>
-

post_title); ?>

-
- 置顶 - 问答投票 - 已解决 - 精华 - 0) : ?> - #name); ?> -
-
- -
post_content); ?>
- - 0 && current_time('timestamp') > $deadline; - $vote_display = (string) jio_get_setting('jio_forum_vote_number_type', 'percentage'); - $already_voted = $user_id > 0 && get_user_meta($user_id, '_jio_forum_vote_' . $id, true) !== ''; - ?> -

- $option) : ?> - - -

已投票

投票已截止

-
- - -

内容已隐藏

请完成访问条件后查看。

- 0) : ?> - 关注版块'; - } elseif (in_array($post_rule, ['points', 'pay'], true)) { - View::action('unlock', $id, '解锁帖子'); - } - ?> - 登录查看 -
- -
- - - - - 编辑 - comment_status === 'open' ? '关闭回复' : '开放回复'); View::action('move', $id, '移动版块'); endif; ?> -
-
- -

已采纳回复 #

- - -
- +\Jio\Forum\PostView::render(); diff --git a/tests/forum/pages-contract.test.cjs b/tests/forum/pages-contract.test.cjs new file mode 100644 index 00000000..29fc651c --- /dev/null +++ b/tests/forum/pages-contract.test.cjs @@ -0,0 +1,63 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const root = path.resolve(__dirname, '..', '..') +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8') + +test('plate pages render the reference header card, tab bar and list', () => { + const listing = read('src/Forum/ListingView.php') + const cards = read('src/Forum/Cards.php') + const view = read('src/Forum/View.php') + assert.match(view, /public static function listing\(\): void \{\s*ListingView::plate\(\);/) + for (const token of ['plate-tab', 'tab-nav-theme', 'affix-header-sm', 'data-forum-tab', 'data-forum-more', 'post_ajax_loader', 'content-wrap', 'content-layout', 'sidebar']) { + assert.ok(listing.includes(token), `plate page missing ${token}`) + } + // Reference header: blurred cover + mask + stats + moderator avatars. + for (const token of ['plate-header-wrap', 'forum-header blur-header', 'abs-blur-bg', 'forum-mask', 'moderator-btns', 'count-mates']) { + assert.ok(cards.includes(token), `plate header missing ${token}`) + } + // Moderator-only tabs (pending / trash) appear on plate pages. + assert.match(listing, /'pending'/) + assert.match(listing, /'trash'/) + // Topping posts are pulled out of the main loop on the first tab. + assert.match(listing, /_jio_forum_pinned/) + assert.match(listing, /post__not_in/) +}) + +test('post detail pages use the reference article shell', () => { + const post = read('src/Forum/PostView.php') + const template = read('templates/pages/page-forum-post.php') + assert.match(template, /PostView::render\(\)/) + for (const token of ['fixed-wrap single-wrap', 'fixed-wrap-content', 'forum-single-cover', 'breadcrumb', 'id="article"', 'article zib-widget forum-article', 'article-header clearfix', 'forum-article-meta', 'wp-posts-content', 'article-tags', 'forum-article-footer', 'footer-actions', 'vote-box forum-vote', 'comments-card']) { + assert.ok(post.includes(token), `post page missing ${token}`) + } + // Banners, permissions and actions stay server-side. + assert.match(post, /Access::can_view\(/) + assert.match(post, /Cards::title\(\$post, 'article-title'/) + assert.match(post, /data-forum-action="score"/) + assert.match(post, /jio_is_show_sidebar\(\)/) +}) + +test('compose pages mirror the reference editor plus settings sidebar', () => { + const edit = read('src/Forum/EditView.php') + const template = read('templates/pages/page-forum-edit.php') + assert.match(template, /EditView::render\(\)/) + for (const token of ['editor-main-box', 'edit-posts-featured', 'edit-posts-title', 'line-form-input', 'jio_forum_content', 'bbs-posts-edit', 'but-average', 'bbs-posts-submit', 'data-forum-draft', 'drop-select', 'allow-view-drop', 'plate-drop', 'type-drop', 'forum-type-vote']) { + assert.ok(edit.includes(token), `compose page missing ${token}`) + } + assert.match(edit, /class="sidebar show-sidebar"/) + assert.match(edit, /wp_editor\(/) +}) + +test('compose page saves draft state and the cover through the REST contract', () => { + const rest = read('src/Forum/RestPosts.php') + const runtime = read('assets/js/forum-runtime.js') + assert.match(rest, /\$requested === 'draft'/) + assert.match(rest, /featured_image_id/) + assert.match(rest, /set_post_thumbnail\(/) + for (const token of ['data-forum-type-set', 'data-forum-view', 'data-forum-plate', 'data-forum-status', 'data-forum-topics', 'data-forum-tags', 'data-forum-cover-file']) { + assert.ok(runtime.includes(token), `compose runtime missing ${token}`) + } +}) diff --git a/tools/admin-contract-report.json b/tools/admin-contract-report.json index 9a18bb7a..0a1fc009 100644 --- a/tools/admin-contract-report.json +++ b/tools/admin-contract-report.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-11-11T07:46:58+00:00", + "generated_at": "2026-11-11T08:16:48+00:00", "errors": [], "warnings": [ "customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",