@commonpub/layer 0.82.0 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/AppToast.vue +1 -1
- package/components/ContentAvatar.vue +98 -0
- package/components/CpubCriteriaBar.vue +88 -0
- package/components/CpubDateTimeField.vue +73 -0
- package/components/CpubMarkdown.vue +3 -1
- package/components/FormatToggle.vue +2 -2
- package/components/ImageUpload.vue +5 -8
- package/components/MirrorDetailModal.vue +3 -1
- package/components/MirrorRequestApproveModal.vue +3 -1
- package/components/ProductEditModal.vue +184 -0
- package/components/RemoteFollowDialog.vue +2 -2
- package/components/SearchSidebar.vue +14 -21
- package/components/ShareToHubModal.vue +3 -1
- package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
- package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
- package/components/blocks/BlockCompareColumnsView.vue +92 -0
- package/components/blocks/BlockContentRenderer.vue +17 -0
- package/components/blocks/BlockCriteriaBarView.vue +25 -0
- package/components/blocks/BlockGalleryView.vue +5 -0
- package/components/blocks/BlockHtmlView.vue +26 -0
- package/components/blocks/BlockImageView.vue +4 -0
- package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
- package/components/blocks/BlockRoadmapView.vue +84 -0
- package/components/blocks/BlockSponsorsView.vue +89 -0
- package/components/blocks/BlockTableView.vue +49 -0
- package/components/blocks/BlockTabsView.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +155 -0
- package/components/contest/ContestCriteriaEditor.vue +79 -0
- package/components/contest/ContestEditor.vue +948 -0
- package/components/contest/ContestEntries.vue +1 -1
- package/components/contest/ContestEntryPrivateData.vue +126 -0
- package/components/contest/ContestHero.vue +114 -186
- package/components/contest/ContestJudgeManager.vue +6 -4
- package/components/contest/ContestJudgingCriteria.vue +5 -21
- package/components/contest/ContestPrizes.vue +8 -1
- package/components/contest/ContestProposalForm.vue +88 -0
- package/components/contest/ContestRules.vue +8 -1
- package/components/contest/ContestSidebar.vue +8 -2
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +3 -2
- package/components/contest/ContestSubmissionField.vue +141 -0
- package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
- package/components/contest/blocks/ContestTabPanel.vue +27 -0
- package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
- package/components/contest/blocks/HtmlBlock.vue +61 -0
- package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
- package/components/contest/blocks/RoadmapBlock.vue +127 -0
- package/components/contest/blocks/SponsorsBlock.vue +127 -0
- package/components/contest/blocks/TableBlock.vue +101 -0
- package/components/contest/blocks/TabsBlock.vue +168 -0
- package/components/editors/ArticleEditor.vue +9 -16
- package/components/editors/ExplainerEditor.vue +8 -5
- package/components/editors/ProjectEditor.vue +13 -10
- package/components/homepage/CustomHtmlSection.vue +11 -2
- package/components/hub/HubProducts.vue +4 -2
- package/components/nav/NavDropdown.vue +1 -5
- package/components/nav/NavLink.vue +2 -0
- package/components/views/ArticleView.vue +3 -56
- package/components/views/ExplainerView.vue +4 -0
- package/components/views/ProjectView.vue +83 -245
- package/composables/useContestEditor.ts +388 -0
- package/composables/useDocsPageTree.ts +154 -0
- package/composables/useDocsSiteSettings.ts +107 -0
- package/composables/useEditorAutosave.ts +131 -0
- package/composables/useEngagement.ts +13 -6
- package/composables/useFeatures.ts +9 -1
- package/composables/useFileUpload.ts +60 -0
- package/composables/useProfileContent.ts +84 -0
- package/composables/useSanitize.ts +38 -4
- package/composables/useScrollSpy.ts +87 -0
- package/layouts/admin.vue +41 -19
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +9 -9
- package/pages/[type]/index.vue +6 -1
- package/pages/admin/api-keys.vue +13 -3
- package/pages/admin/features.vue +2 -0
- package/pages/admin/federation.vue +1 -1
- package/pages/admin/layouts/[id].vue +30 -2
- package/pages/admin/settings.vue +2 -1
- package/pages/admin/users.vue +1 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -769
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +93 -7
- package/pages/contests/[slug]/judge.vue +49 -26
- package/pages/contests/create.vue +5 -466
- package/pages/contests/index.vue +7 -2
- package/pages/cookies.vue +1 -1
- package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
- package/pages/docs/[siteSlug]/edit.vue +93 -231
- package/pages/events/[slug]/edit.vue +20 -20
- package/pages/events/create.vue +18 -18
- package/pages/events/index.vue +7 -2
- package/pages/hubs/[slug]/index.vue +34 -9
- package/pages/hubs/[slug]/invites.vue +312 -0
- package/pages/hubs/[slug]/members.vue +128 -0
- package/pages/hubs/[slug]/posts/[postId].vue +2 -2
- package/pages/hubs/index.vue +6 -1
- package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
- package/pages/learn/index.vue +8 -1
- package/pages/messages/index.vue +1 -1
- package/pages/mirror/[id].vue +1 -1
- package/pages/products/[slug].vue +55 -2
- package/pages/products/index.vue +6 -1
- package/pages/settings/account.vue +8 -8
- package/pages/settings/profile.vue +23 -14
- package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
- package/pages/u/[username]/followers.vue +11 -3
- package/pages/u/[username]/following.vue +10 -8
- package/pages/u/[username]/index.vue +73 -7
- package/pages/videos/index.vue +13 -10
- package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
- package/server/api/admin/api-keys/[id].delete.ts +2 -2
- package/server/api/admin/api-keys/index.get.ts +1 -0
- package/server/api/admin/api-keys/index.post.ts +1 -0
- package/server/api/admin/federation/refederate.post.ts +18 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
- package/server/api/admin/layouts/[id].delete.ts +1 -4
- package/server/api/admin/layouts/[id].get.ts +1 -4
- package/server/api/admin/layouts/[id].put.ts +1 -4
- package/server/api/auth/federated/login.post.ts +12 -5
- package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
- package/server/api/content/[id]/build.get.ts +11 -0
- package/server/api/content/[id]/report.post.ts +2 -0
- package/server/api/content/[id]/versions.get.ts +15 -0
- package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
- package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
- package/server/api/contests/[slug]/export.get.ts +43 -0
- package/server/api/contests/[slug]/judge.post.ts +8 -2
- package/server/api/contests/[slug]/proposal.post.ts +36 -0
- package/server/api/contests/[slug]/user-search.get.ts +30 -0
- package/server/api/contests/index.post.ts +1 -1
- package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
- package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
- package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
- package/server/api/docs/[siteSlug]/search.get.ts +7 -1
- package/server/api/events/[slug]/attendees.get.ts +10 -0
- package/server/api/events/[slug].get.ts +9 -0
- package/server/api/events/index.get.ts +8 -1
- package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
- package/server/api/federation/content/[id]/build.get.ts +10 -0
- package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
- package/server/api/hubs/[slug]/invites.get.ts +5 -3
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
- package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
- package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
- package/server/api/hubs/[slug]/requests.get.ts +20 -0
- package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
- package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
- package/server/api/products/[id].delete.ts +22 -2
- package/server/api/registry/ping.post.ts +17 -3
- package/server/api/search/index.get.ts +5 -3
- package/server/api/social/bookmark.get.ts +1 -0
- package/server/api/social/bookmark.post.ts +1 -0
- package/server/api/social/bookmarks.get.ts +1 -0
- package/server/api/social/comments/[id].delete.ts +1 -0
- package/server/api/social/comments.get.ts +1 -0
- package/server/api/social/comments.post.ts +1 -0
- package/server/api/social/like.get.ts +1 -0
- package/server/api/social/like.post.ts +1 -0
- package/server/api/users/[username]/content.get.ts +15 -3
- package/server/api/users/[username]/follow.delete.ts +1 -0
- package/server/api/users/[username]/follow.post.ts +1 -0
- package/server/api/users/[username]/followers.get.ts +2 -1
- package/server/api/users/[username]/following.get.ts +2 -1
- package/server/middleware/content-ap.ts +8 -3
- package/server/middleware/csrf.ts +93 -0
- package/server/plugins/federation-hub-sync.ts +48 -17
- package/server/plugins/notification-email.ts +22 -3
- package/server/routes/hubs/[slug]/inbox.ts +13 -1
- package/server/routes/inbox.ts +14 -1
- package/server/routes/users/[username]/inbox.ts +13 -1
- package/server/utils/inbox.ts +7 -2
- package/server/utils/validate.ts +22 -0
- package/theme/base.css +5 -0
- package/theme/prose.css +20 -0
- package/theme/stoa-dark.css +4 -0
- package/types/contestBlocks.ts +122 -0
- package/utils/contestBlocks.ts +107 -0
- package/utils/contestBody.ts +25 -0
- package/utils/contestStages.ts +62 -0
- package/utils/contestSubmission.ts +97 -0
- package/utils/datetime.ts +45 -0
- package/utils/projectBlocks.ts +162 -0
- package/components/editors/BlogEditor.vue +0 -648
package/components/AppToast.vue
CHANGED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Headless author avatar shared by the content views (ProjectView, ArticleView).
|
|
4
|
+
*
|
|
5
|
+
* Renders an <img class="cpub-av"> when `src` is present, else an initials
|
|
6
|
+
* <div class="cpub-av"> (first two letters of `name`, uppercased, falling back
|
|
7
|
+
* to 'CP'). The .cpub-av CSS lives HERE — Vue scoped styles are hashed per
|
|
8
|
+
* component instance, so the avatar rules cannot be left behind in the views;
|
|
9
|
+
* they must travel with the markup. The .cpub-av block (square-lock, border,
|
|
10
|
+
* object-fit, div-only flex centering) is copied verbatim from the pre-dedup
|
|
11
|
+
* ProjectView/ArticleView so behaviour is identical.
|
|
12
|
+
*
|
|
13
|
+
* Size is per-call: ProjectView's byline was 36px, ArticleView's byline 44px,
|
|
14
|
+
* ArticleView's author card 64px — so callers pass `size`/`fontSize` (the only
|
|
15
|
+
* value that ever diverged between the two views).
|
|
16
|
+
*/
|
|
17
|
+
const props = withDefaults(
|
|
18
|
+
defineProps<{
|
|
19
|
+
src?: string | null;
|
|
20
|
+
name: string;
|
|
21
|
+
/** Avatar diameter in px. */
|
|
22
|
+
size?: number;
|
|
23
|
+
/** Initials font-size in px. */
|
|
24
|
+
fontSize?: number;
|
|
25
|
+
}>(),
|
|
26
|
+
{ src: null, size: 28, fontSize: 10 },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const initials = computed(() => props.name?.slice(0, 2).toUpperCase() || 'CP');
|
|
30
|
+
const cssVars = computed(() => ({
|
|
31
|
+
'--cpub-av-size': `${props.size}px`,
|
|
32
|
+
'--cpub-av-font': `${props.fontSize}px`,
|
|
33
|
+
}));
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<img
|
|
38
|
+
v-if="src"
|
|
39
|
+
:src="src"
|
|
40
|
+
:alt="name"
|
|
41
|
+
class="cpub-av"
|
|
42
|
+
:style="cssVars"
|
|
43
|
+
/>
|
|
44
|
+
<div v-else class="cpub-av" :style="cssVars">{{ initials }}</div>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<style scoped>
|
|
48
|
+
/* ── AVATARS ──
|
|
49
|
+
* Two render modes share the .cpub-av class:
|
|
50
|
+
* <img class="cpub-av" ...> ← avatar photo
|
|
51
|
+
* <div class="cpub-av">JD</div> ← initials fallback when no avatar
|
|
52
|
+
*
|
|
53
|
+
* Sizing + border-radius is shared. But `display: flex` MUST NOT apply to
|
|
54
|
+
* the <img> — when a replaced element gets `display: flex` set, browsers
|
|
55
|
+
* (notably Chromium) treat the img content render-box inconsistently and
|
|
56
|
+
* the inline `object-fit: cover` is silently dropped, producing a squished
|
|
57
|
+
* (stretched-to-box) image instead of a center-cropped one. Visible on
|
|
58
|
+
* deveco.io blog pages where author avatars are vertical photos (e.g.
|
|
59
|
+
* 816×1456) rendered into a 44×44 square.
|
|
60
|
+
*
|
|
61
|
+
* Fix: scope display:flex centering to the div variant only.
|
|
62
|
+
*/
|
|
63
|
+
.cpub-av {
|
|
64
|
+
--cpub-av-size: 28px;
|
|
65
|
+
--cpub-av-font: 10px;
|
|
66
|
+
width: var(--cpub-av-size);
|
|
67
|
+
height: var(--cpub-av-size);
|
|
68
|
+
/* Hard-lock to a square. Without min/max clamps, a global img reset or a
|
|
69
|
+
dropped dimension lets the <img> fall back to its intrinsic aspect ratio,
|
|
70
|
+
so a portrait photo renders as a tall oval (the deveco blog-avatar bug -
|
|
71
|
+
visible even on wide viewports, so it's not flex compression). min/max on
|
|
72
|
+
BOTH axes clamp the used size regardless of what sets width/height. */
|
|
73
|
+
min-width: var(--cpub-av-size);
|
|
74
|
+
max-width: var(--cpub-av-size);
|
|
75
|
+
min-height: var(--cpub-av-size);
|
|
76
|
+
max-height: var(--cpub-av-size);
|
|
77
|
+
border-radius: 50%;
|
|
78
|
+
background: var(--surface3);
|
|
79
|
+
border: var(--border-width-default) solid var(--border);
|
|
80
|
+
flex-shrink: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
div.cpub-av {
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
justify-content: center;
|
|
87
|
+
font-size: var(--cpub-av-font);
|
|
88
|
+
font-weight: 700;
|
|
89
|
+
color: var(--text-dim);
|
|
90
|
+
font-family: var(--font-mono);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* Defensive: even when consumers forget the inline `object-fit:cover`,
|
|
94
|
+
img.cpub-av crops instead of stretching. */
|
|
95
|
+
img.cpub-av {
|
|
96
|
+
object-fit: cover;
|
|
97
|
+
}
|
|
98
|
+
</style>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* CpubCriteriaBar — the one criteria-weighting visual, shared by the `criteriaBar`
|
|
4
|
+
* block (view + edit preview) and the public "Judging Criteria" section. One thin,
|
|
5
|
+
* seamless, sharp stacked bar (no gaps, no rounding, no in-segment text) where each
|
|
6
|
+
* criterion is a proportional colored segment; all labels live in a legend
|
|
7
|
+
* (swatch · name · share, with an optional description line). The bar is decorative
|
|
8
|
+
* (aria-hidden); the legend + an sr-only summary carry the data. Pattern: the
|
|
9
|
+
* iOS-storage / GitHub-language bar — distinct categorical colors, external legend.
|
|
10
|
+
*/
|
|
11
|
+
import { criteriaBar, type CriteriaBarItem } from '../utils/contestBlocks';
|
|
12
|
+
|
|
13
|
+
// NB: boolean props cast an absent value to `false` (Vue boolean casting), so the
|
|
14
|
+
// legend must default true explicitly — otherwise callers that omit it lose the legend.
|
|
15
|
+
const props = withDefaults(
|
|
16
|
+
defineProps<{
|
|
17
|
+
items?: CriteriaBarItem[];
|
|
18
|
+
heading?: string;
|
|
19
|
+
showLegend?: boolean;
|
|
20
|
+
}>(),
|
|
21
|
+
{ items: () => [], heading: '', showLegend: true },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const data = computed(() => criteriaBar(props.items));
|
|
25
|
+
const segments = computed(() => data.value.rows.filter((r) => r.pct > 0));
|
|
26
|
+
const showLegend = computed(() => props.showLegend);
|
|
27
|
+
const hasDesc = computed(() => data.value.rows.some((r) => r.description));
|
|
28
|
+
const summary = computed(() =>
|
|
29
|
+
data.value.rows.map((r) => (data.value.total > 0 ? `${r.label} ${r.pct}%` : r.label)).join(', '),
|
|
30
|
+
);
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<figure v-if="data.rows.length" class="cpub-cbar" role="group" :aria-label="heading || 'Criteria weighting'">
|
|
35
|
+
<figcaption v-if="heading" class="cpub-cbar-heading">{{ heading }}</figcaption>
|
|
36
|
+
|
|
37
|
+
<div v-if="segments.length" class="cpub-cbar-track" aria-hidden="true">
|
|
38
|
+
<span
|
|
39
|
+
v-for="(s, i) in segments"
|
|
40
|
+
:key="i"
|
|
41
|
+
class="cpub-cbar-seg"
|
|
42
|
+
:style="{ width: `${s.pct}%`, background: s.colorVar }"
|
|
43
|
+
:title="`${s.label} — ${s.pct}%`"
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<ul v-if="showLegend" class="cpub-cbar-legend" :class="{ 'cpub-cbar-legend--rows': hasDesc }">
|
|
48
|
+
<li v-for="(r, i) in data.rows" :key="i" class="cpub-cbar-li">
|
|
49
|
+
<span class="cpub-cbar-dot" :style="{ background: r.colorVar }" aria-hidden="true" />
|
|
50
|
+
<span class="cpub-cbar-name">{{ r.label }}</span>
|
|
51
|
+
<span v-if="data.total > 0" class="cpub-cbar-val">{{ r.pct }}%</span>
|
|
52
|
+
<span v-if="r.description" class="cpub-cbar-desc">{{ r.description }}</span>
|
|
53
|
+
</li>
|
|
54
|
+
</ul>
|
|
55
|
+
|
|
56
|
+
<span class="cpub-sr-only">Criteria weighting: {{ summary }}.</span>
|
|
57
|
+
</figure>
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<style scoped>
|
|
61
|
+
.cpub-cbar { margin: 0; }
|
|
62
|
+
.cpub-cbar-heading { font-size: 11px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); margin: 0 0 8px; }
|
|
63
|
+
|
|
64
|
+
/* The bar: thin, seamless (segments butt edge-to-edge), sharp (no radius leak). */
|
|
65
|
+
.cpub-cbar-track {
|
|
66
|
+
display: flex; width: 100%; height: 10px; overflow: hidden;
|
|
67
|
+
border-radius: 0; background: var(--surface2);
|
|
68
|
+
border: var(--border-width-default) solid var(--border);
|
|
69
|
+
}
|
|
70
|
+
.cpub-cbar-seg { height: 100%; min-width: 2px; border-radius: 0; }
|
|
71
|
+
|
|
72
|
+
/* Legend — compact wrap by default; one row per item when descriptions exist. */
|
|
73
|
+
.cpub-cbar-legend { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
|
74
|
+
.cpub-cbar-li { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
|
|
75
|
+
.cpub-cbar-dot { width: 10px; height: 10px; flex-shrink: 0; border-radius: 0; border: var(--border-width-default) solid var(--border2); }
|
|
76
|
+
.cpub-cbar-name { font-size: 12px; color: var(--text); font-weight: 600; }
|
|
77
|
+
.cpub-cbar-val { font-size: 11px; color: var(--text-faint); font-family: var(--font-mono); }
|
|
78
|
+
.cpub-cbar-desc { display: none; }
|
|
79
|
+
|
|
80
|
+
/* Rows mode: a scannable list with the weight right-aligned + a description line. */
|
|
81
|
+
.cpub-cbar-legend--rows { flex-direction: column; gap: 12px; }
|
|
82
|
+
.cpub-cbar-legend--rows .cpub-cbar-li { display: grid; grid-template-columns: auto 1fr auto; align-items: baseline; gap: 3px 10px; width: 100%; }
|
|
83
|
+
.cpub-cbar-legend--rows .cpub-cbar-dot { align-self: center; }
|
|
84
|
+
.cpub-cbar-legend--rows .cpub-cbar-val { justify-self: end; }
|
|
85
|
+
.cpub-cbar-legend--rows .cpub-cbar-desc { display: block; grid-column: 2 / -1; font-size: 12px; color: var(--text-dim); line-height: 1.5; }
|
|
86
|
+
|
|
87
|
+
.cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
|
|
88
|
+
</style>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Themed datetime field — one offset-correct, theme-aware replacement for raw
|
|
4
|
+
* `<input type="datetime-local">` across the contest editor (and reusable
|
|
5
|
+
* elsewhere). The model value is an ISO instant; the control speaks the viewer's
|
|
6
|
+
* local wall-clock via the shared utils/datetime helpers (no UTC shift, the bug
|
|
7
|
+
* fixed in Phase 1). `min`/`max` accept ISO and constrain the native picker (e.g.
|
|
8
|
+
* a stage's end can't precede its start). The native popup themes correctly via
|
|
9
|
+
* `color-scheme` on :root (packages/ui/theme/base.css).
|
|
10
|
+
*/
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
modelValue?: string | null;
|
|
13
|
+
label?: string;
|
|
14
|
+
min?: string | null;
|
|
15
|
+
max?: string | null;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Explicit id for the input; otherwise an SSR-safe one is generated. */
|
|
19
|
+
id?: string;
|
|
20
|
+
}>();
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{ 'update:modelValue': [value: string | undefined] }>();
|
|
23
|
+
|
|
24
|
+
const generatedId = useId();
|
|
25
|
+
const fieldId = computed(() => props.id ?? `cpub-dt-${generatedId}`);
|
|
26
|
+
|
|
27
|
+
// The local wall-clock is timezone-dependent, so the server (its TZ) and the
|
|
28
|
+
// client (the viewer's TZ) compute different value/min/max strings. Vue flags the
|
|
29
|
+
// hydration mismatch and, worse, does NOT rectify it in production (the viewer
|
|
30
|
+
// would see the SERVER's timezone). Defer the conversion to the client: render
|
|
31
|
+
// empty on the server and the first hydration tick, then fill once mounted, so
|
|
32
|
+
// SSR == hydration and the local value only appears client-side.
|
|
33
|
+
const mounted = ref(false);
|
|
34
|
+
onMounted(() => { mounted.value = true; });
|
|
35
|
+
const localValue = computed(() => (mounted.value ? toLocalInput(props.modelValue) : ''));
|
|
36
|
+
const localMin = computed(() => (mounted.value ? toLocalInput(props.min) || undefined : undefined));
|
|
37
|
+
const localMax = computed(() => (mounted.value ? toLocalInput(props.max) || undefined : undefined));
|
|
38
|
+
|
|
39
|
+
function onInput(e: Event): void {
|
|
40
|
+
emit('update:modelValue', fromLocalInput((e.target as HTMLInputElement).value));
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<div class="cpub-datetime-field">
|
|
46
|
+
<label v-if="label" :for="fieldId" class="cpub-form-label">
|
|
47
|
+
{{ label }}<span v-if="required" class="cpub-datetime-req" aria-hidden="true"> *</span>
|
|
48
|
+
</label>
|
|
49
|
+
<input
|
|
50
|
+
:id="fieldId"
|
|
51
|
+
type="datetime-local"
|
|
52
|
+
class="cpub-input"
|
|
53
|
+
:value="localValue"
|
|
54
|
+
:min="localMin"
|
|
55
|
+
:max="localMax"
|
|
56
|
+
:required="required"
|
|
57
|
+
:disabled="disabled"
|
|
58
|
+
:aria-label="label ? undefined : 'Date and time'"
|
|
59
|
+
@input="onInput"
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<style scoped>
|
|
65
|
+
.cpub-datetime-field {
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-direction: column;
|
|
68
|
+
gap: var(--space-1);
|
|
69
|
+
}
|
|
70
|
+
.cpub-datetime-req {
|
|
71
|
+
color: var(--red);
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
@@ -28,7 +28,9 @@ const props = defineProps<{
|
|
|
28
28
|
const trimmed = computed(() => (props.source ?? '').trim());
|
|
29
29
|
const isHtml = computed(() => props.format === 'html');
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// neutralizeColors: drop hardcoded color literals so the themed `.cpub-md-html`
|
|
32
|
+
// baseline shows through (dark-mode-safe); author `var(--*)`/currentColor are kept.
|
|
33
|
+
const richHtml = computed(() => (isHtml.value && trimmed.value ? sanitizeRichHtml(trimmed.value, { neutralizeColors: true }) : ''));
|
|
32
34
|
|
|
33
35
|
const blocks = computed<BlockTuple[]>(() => {
|
|
34
36
|
if (isHtml.value || !trimmed.value) return [];
|
|
@@ -8,7 +8,7 @@ const model = defineModel<'markdown' | 'html'>({ default: 'markdown' });
|
|
|
8
8
|
</script>
|
|
9
9
|
|
|
10
10
|
<template>
|
|
11
|
-
<div class="cpub-fmt-toggle" role="
|
|
11
|
+
<div class="cpub-fmt-toggle" role="group" aria-label="Field format">
|
|
12
12
|
<button
|
|
13
13
|
type="button"
|
|
14
14
|
class="cpub-fmt-opt"
|
|
@@ -48,7 +48,7 @@ const model = defineModel<'markdown' | 'html'>({ default: 'markdown' });
|
|
|
48
48
|
}
|
|
49
49
|
.cpub-fmt-active {
|
|
50
50
|
background: var(--accent);
|
|
51
|
-
color: var(--accent
|
|
51
|
+
color: var(--color-on-accent);
|
|
52
52
|
}
|
|
53
53
|
.cpub-fmt-opt:focus-visible {
|
|
54
54
|
outline: 2px solid var(--accent);
|
|
@@ -13,6 +13,8 @@ const emit = defineEmits<{
|
|
|
13
13
|
'update:modelValue': [url: string];
|
|
14
14
|
}>();
|
|
15
15
|
|
|
16
|
+
const { uploadFile } = useFileUpload();
|
|
17
|
+
|
|
16
18
|
const uploading = ref(false);
|
|
17
19
|
const error = ref('');
|
|
18
20
|
const fileInput = ref<HTMLInputElement | null>(null);
|
|
@@ -59,14 +61,9 @@ async function onCropped(blob: Blob): Promise<void> {
|
|
|
59
61
|
error.value = '';
|
|
60
62
|
try {
|
|
61
63
|
const ext = blob.type === 'image/png' ? 'png' : 'jpg';
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const result = await $fetch<{ url: string }>('/api/files/upload', {
|
|
67
|
-
method: 'POST',
|
|
68
|
-
body: formData,
|
|
69
|
-
});
|
|
64
|
+
const file = new File([blob], `${props.purpose}.${ext}`, { type: blob.type || 'image/jpeg' });
|
|
65
|
+
|
|
66
|
+
const result = await uploadFile(file, props.purpose);
|
|
70
67
|
|
|
71
68
|
emit('update:modelValue', result.url);
|
|
72
69
|
} catch (err: unknown) {
|
|
@@ -92,6 +92,7 @@ async function remove(): Promise<void> {
|
|
|
92
92
|
</script>
|
|
93
93
|
|
|
94
94
|
<template>
|
|
95
|
+
<Teleport to="body">
|
|
95
96
|
<div class="cpub-modal-backdrop" @click.self="emit('close')">
|
|
96
97
|
<div ref="contentRef" class="cpub-modal-content" role="dialog" aria-modal="true" aria-labelledby="cpub-mirror-modal-title">
|
|
97
98
|
<div class="cpub-modal-header">
|
|
@@ -170,12 +171,13 @@ async function remove(): Promise<void> {
|
|
|
170
171
|
</div>
|
|
171
172
|
</div>
|
|
172
173
|
</div>
|
|
174
|
+
</Teleport>
|
|
173
175
|
</template>
|
|
174
176
|
|
|
175
177
|
<style scoped>
|
|
176
178
|
.cpub-modal-backdrop {
|
|
177
179
|
position: fixed; inset: 0; background: var(--color-surface-scrim, rgba(0,0,0,0.5));
|
|
178
|
-
z-index:
|
|
180
|
+
z-index: var(--z-modal-backdrop); display: flex; align-items: center; justify-content: center; padding: 16px;
|
|
179
181
|
}
|
|
180
182
|
.cpub-modal-content {
|
|
181
183
|
background: var(--surface); border: var(--border-width-default) solid var(--border);
|
|
@@ -77,6 +77,7 @@ async function reject(): Promise<void> {
|
|
|
77
77
|
</script>
|
|
78
78
|
|
|
79
79
|
<template>
|
|
80
|
+
<Teleport to="body">
|
|
80
81
|
<div class="cpub-modal-backdrop" @click.self="emit('close')">
|
|
81
82
|
<div ref="contentRef" class="cpub-modal-content" role="dialog" aria-modal="true" aria-labelledby="cpub-mr-modal-title">
|
|
82
83
|
<div class="cpub-modal-header">
|
|
@@ -123,12 +124,13 @@ async function reject(): Promise<void> {
|
|
|
123
124
|
</div>
|
|
124
125
|
</div>
|
|
125
126
|
</div>
|
|
127
|
+
</Teleport>
|
|
126
128
|
</template>
|
|
127
129
|
|
|
128
130
|
<style scoped>
|
|
129
131
|
.cpub-modal-backdrop {
|
|
130
132
|
position: fixed; inset: 0; background: var(--color-surface-scrim, rgba(0,0,0,0.5));
|
|
131
|
-
z-index:
|
|
133
|
+
z-index: var(--z-modal-backdrop); display: flex; align-items: center; justify-content: center; padding: 16px;
|
|
132
134
|
}
|
|
133
135
|
.cpub-modal-content {
|
|
134
136
|
background: var(--surface); border: var(--border-width-default) solid var(--border);
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface EditableProduct {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
description: string | null;
|
|
6
|
+
category: string | null;
|
|
7
|
+
purchaseUrl: string | null;
|
|
8
|
+
datasheetUrl: string | null;
|
|
9
|
+
status: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
product: EditableProduct;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
close: [];
|
|
18
|
+
updated: [product: { slug: string }];
|
|
19
|
+
}>();
|
|
20
|
+
|
|
21
|
+
const toast = useToast();
|
|
22
|
+
|
|
23
|
+
const formName = ref(props.product.name);
|
|
24
|
+
const formDescription = ref(props.product.description ?? '');
|
|
25
|
+
const formCategory = ref(props.product.category ?? 'other');
|
|
26
|
+
const formPurchaseUrl = ref(props.product.purchaseUrl ?? '');
|
|
27
|
+
const formDatasheetUrl = ref(props.product.datasheetUrl ?? '');
|
|
28
|
+
const formStatus = ref(props.product.status ?? 'active');
|
|
29
|
+
const saving = ref(false);
|
|
30
|
+
|
|
31
|
+
// Parent mounts/unmounts this modal via v-if, so it's always "open" while
|
|
32
|
+
// mounted. A local ref flipped on mount drives useFocusTrap's watcher.
|
|
33
|
+
const contentRef = ref<HTMLElement | null>(null);
|
|
34
|
+
const visible = ref(false);
|
|
35
|
+
onMounted(() => { visible.value = true; });
|
|
36
|
+
useFocusTrap(contentRef, () => visible.value, () => emit('close'));
|
|
37
|
+
|
|
38
|
+
async function handleSave(): Promise<void> {
|
|
39
|
+
if (!formName.value.trim()) return;
|
|
40
|
+
saving.value = true;
|
|
41
|
+
try {
|
|
42
|
+
const updated = await $fetch<{ slug: string }>(`/api/products/${props.product.id}`, {
|
|
43
|
+
method: 'PUT',
|
|
44
|
+
body: {
|
|
45
|
+
name: formName.value,
|
|
46
|
+
description: formDescription.value || undefined,
|
|
47
|
+
category: formCategory.value,
|
|
48
|
+
purchaseUrl: formPurchaseUrl.value || undefined,
|
|
49
|
+
datasheetUrl: formDatasheetUrl.value || undefined,
|
|
50
|
+
status: formStatus.value,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
toast.success('Product updated');
|
|
54
|
+
emit('updated', updated);
|
|
55
|
+
emit('close');
|
|
56
|
+
} catch {
|
|
57
|
+
toast.error('Failed to update product');
|
|
58
|
+
} finally {
|
|
59
|
+
saving.value = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<Teleport to="body">
|
|
66
|
+
<div class="cpub-modal-backdrop" @click.self="emit('close')">
|
|
67
|
+
<div ref="contentRef" class="cpub-modal-content" role="dialog" aria-modal="true" aria-labelledby="cpub-edit-product-title">
|
|
68
|
+
<div class="cpub-modal-header">
|
|
69
|
+
<h3 id="cpub-edit-product-title" class="cpub-modal-title">Edit Product</h3>
|
|
70
|
+
<button class="cpub-modal-close" aria-label="Close" @click="emit('close')"><i class="fa-solid fa-xmark"></i></button>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<form class="cpub-resource-form" @submit.prevent="handleSave">
|
|
74
|
+
<label class="cpub-field-label" for="cpub-edit-product-name">Name</label>
|
|
75
|
+
<input id="cpub-edit-product-name" v-model="formName" type="text" placeholder="Product name" class="cpub-input" required />
|
|
76
|
+
|
|
77
|
+
<label class="cpub-field-label" for="cpub-edit-product-desc">Description</label>
|
|
78
|
+
<input id="cpub-edit-product-desc" v-model="formDescription" type="text" placeholder="Short description (optional)" class="cpub-input" />
|
|
79
|
+
|
|
80
|
+
<label class="cpub-field-label" for="cpub-edit-product-category">Category</label>
|
|
81
|
+
<select id="cpub-edit-product-category" v-model="formCategory" class="cpub-input">
|
|
82
|
+
<option value="microcontroller">Microcontroller</option>
|
|
83
|
+
<option value="sbc">SBC</option>
|
|
84
|
+
<option value="sensor">Sensor</option>
|
|
85
|
+
<option value="actuator">Actuator</option>
|
|
86
|
+
<option value="display">Display</option>
|
|
87
|
+
<option value="communication">Communication</option>
|
|
88
|
+
<option value="power">Power</option>
|
|
89
|
+
<option value="mechanical">Mechanical</option>
|
|
90
|
+
<option value="software">Software</option>
|
|
91
|
+
<option value="tool">Tool</option>
|
|
92
|
+
<option value="other">Other</option>
|
|
93
|
+
</select>
|
|
94
|
+
|
|
95
|
+
<label class="cpub-field-label" for="cpub-edit-product-purchase">Purchase URL</label>
|
|
96
|
+
<input id="cpub-edit-product-purchase" v-model="formPurchaseUrl" type="url" placeholder="Purchase URL (optional)" class="cpub-input" />
|
|
97
|
+
|
|
98
|
+
<label class="cpub-field-label" for="cpub-edit-product-datasheet">Datasheet URL</label>
|
|
99
|
+
<input id="cpub-edit-product-datasheet" v-model="formDatasheetUrl" type="url" placeholder="Datasheet URL (optional)" class="cpub-input" />
|
|
100
|
+
|
|
101
|
+
<label class="cpub-field-label" for="cpub-edit-product-status">Status</label>
|
|
102
|
+
<select id="cpub-edit-product-status" v-model="formStatus" class="cpub-input">
|
|
103
|
+
<option value="active">Active</option>
|
|
104
|
+
<option value="discontinued">Discontinued</option>
|
|
105
|
+
<option value="preview">Preview</option>
|
|
106
|
+
</select>
|
|
107
|
+
|
|
108
|
+
<div class="cpub-modal-actions">
|
|
109
|
+
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !formName.trim()">
|
|
110
|
+
{{ saving ? 'Saving...' : 'Save Changes' }}
|
|
111
|
+
</button>
|
|
112
|
+
<button type="button" class="cpub-btn" @click="emit('close')">Cancel</button>
|
|
113
|
+
</div>
|
|
114
|
+
</form>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</Teleport>
|
|
118
|
+
</template>
|
|
119
|
+
|
|
120
|
+
<style scoped>
|
|
121
|
+
.cpub-modal-backdrop {
|
|
122
|
+
position: fixed;
|
|
123
|
+
inset: 0;
|
|
124
|
+
background: var(--color-surface-scrim, rgba(0,0,0,0.5));
|
|
125
|
+
z-index: var(--z-modal-backdrop);
|
|
126
|
+
display: flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
justify-content: center;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.cpub-modal-content {
|
|
132
|
+
background: var(--surface);
|
|
133
|
+
border: var(--border-width-default) solid var(--border);
|
|
134
|
+
box-shadow: var(--shadow-lg);
|
|
135
|
+
-webkit-backdrop-filter: var(--surface-backdrop, none);
|
|
136
|
+
backdrop-filter: var(--surface-backdrop, none);
|
|
137
|
+
padding: 24px;
|
|
138
|
+
max-width: 420px;
|
|
139
|
+
width: 90vw;
|
|
140
|
+
max-height: 90vh;
|
|
141
|
+
overflow-y: auto;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.cpub-modal-header {
|
|
145
|
+
display: flex;
|
|
146
|
+
justify-content: space-between;
|
|
147
|
+
align-items: center;
|
|
148
|
+
margin-bottom: 12px;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.cpub-modal-title { font-size: 16px; font-weight: 700; }
|
|
152
|
+
|
|
153
|
+
.cpub-modal-close {
|
|
154
|
+
background: none;
|
|
155
|
+
border: none;
|
|
156
|
+
color: var(--text-faint);
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
font-size: 14px;
|
|
159
|
+
padding: 4px;
|
|
160
|
+
}
|
|
161
|
+
.cpub-modal-close:hover { color: var(--text); }
|
|
162
|
+
|
|
163
|
+
.cpub-resource-form {
|
|
164
|
+
display: flex;
|
|
165
|
+
flex-direction: column;
|
|
166
|
+
gap: 8px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.cpub-field-label {
|
|
170
|
+
font-family: var(--font-mono);
|
|
171
|
+
font-size: 10px;
|
|
172
|
+
font-weight: 700;
|
|
173
|
+
text-transform: uppercase;
|
|
174
|
+
letter-spacing: 0.1em;
|
|
175
|
+
color: var(--text-faint);
|
|
176
|
+
margin-top: 6px;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.cpub-modal-actions {
|
|
180
|
+
display: flex;
|
|
181
|
+
gap: 8px;
|
|
182
|
+
margin-top: 12px;
|
|
183
|
+
}
|
|
184
|
+
</style>
|
|
@@ -44,9 +44,9 @@ useFocusTrap(dialogRef, () => open.value, close);
|
|
|
44
44
|
<template>
|
|
45
45
|
<Teleport to="body">
|
|
46
46
|
<div v-if="open" class="cpub-rfd-overlay" @click.self="close">
|
|
47
|
-
<div ref="dialogRef" class="cpub-rfd-dialog" role="dialog" aria-modal="true">
|
|
47
|
+
<div ref="dialogRef" class="cpub-rfd-dialog" role="dialog" aria-modal="true" aria-labelledby="cpub-rfd-title">
|
|
48
48
|
<div class="cpub-rfd-header">
|
|
49
|
-
<h3>Follow from your instance</h3>
|
|
49
|
+
<h3 id="cpub-rfd-title">Follow from your instance</h3>
|
|
50
50
|
<button class="cpub-rfd-close" aria-label="Close" @click="close">
|
|
51
51
|
<i class="fa-solid fa-xmark"></i>
|
|
52
52
|
</button>
|
|
@@ -22,10 +22,11 @@ const { hubs: hubsEnabled } = useFeatures();
|
|
|
22
22
|
over an empty list reads as broken on quiet/new instances). -->
|
|
23
23
|
<div v-if="trendingSearches?.length" class="cpub-sb-block">
|
|
24
24
|
<div class="cpub-sb-heading">Trending Searches</div>
|
|
25
|
-
<
|
|
26
|
-
<
|
|
25
|
+
<div class="cpub-pop-search-list" role="group" aria-label="Trending searches">
|
|
26
|
+
<button
|
|
27
27
|
v-for="(item, idx) in trendingSearches?.slice(0, 8) ?? []"
|
|
28
28
|
:key="idx"
|
|
29
|
+
type="button"
|
|
29
30
|
class="cpub-pop-search-item"
|
|
30
31
|
@click="emit('search', item.query)"
|
|
31
32
|
>
|
|
@@ -35,23 +36,25 @@ const { hubs: hubsEnabled } = useFeatures();
|
|
|
35
36
|
<i :class="item.trend > 0 ? 'fa-solid fa-arrow-trend-up' : 'fa-solid fa-minus'" style="font-size: 9px"></i>
|
|
36
37
|
<template v-if="item.trend > 0">+{{ item.trend }}%</template>
|
|
37
38
|
</span>
|
|
38
|
-
</
|
|
39
|
-
</
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
40
41
|
</div>
|
|
41
42
|
|
|
42
43
|
<!-- Suggested Tags -->
|
|
43
44
|
<div class="cpub-sb-block">
|
|
44
45
|
<div class="cpub-sb-heading">Suggested Tags</div>
|
|
45
46
|
<div class="cpub-tag-cloud">
|
|
46
|
-
<
|
|
47
|
+
<button
|
|
47
48
|
v-for="tag in suggestedTags"
|
|
48
49
|
:key="tag"
|
|
50
|
+
type="button"
|
|
49
51
|
class="cpub-s-tag"
|
|
50
52
|
:class="{ active: activeTags.includes(tag) }"
|
|
53
|
+
:aria-pressed="activeTags.includes(tag)"
|
|
51
54
|
@click="emit('toggle-tag', tag)"
|
|
52
55
|
>
|
|
53
56
|
{{ tag }}
|
|
54
|
-
</
|
|
57
|
+
</button>
|
|
55
58
|
</div>
|
|
56
59
|
</div>
|
|
57
60
|
|
|
@@ -60,10 +63,10 @@ const { hubs: hubsEnabled } = useFeatures();
|
|
|
60
63
|
<div class="cpub-sb-heading">Browse by Category</div>
|
|
61
64
|
<p class="cpub-no-results-note">Not finding what you need? Try browsing a category directly.</p>
|
|
62
65
|
<div class="cpub-cat-grid">
|
|
63
|
-
<
|
|
66
|
+
<button v-for="cat in categories" :key="cat.label" type="button" class="cpub-cat-cell" @click="emit('set-category', cat.label)">
|
|
64
67
|
<span class="cpub-cat-icon"><i :class="cat.icon"></i></span>
|
|
65
68
|
<span class="cpub-cat-label">{{ cat.label }}</span>
|
|
66
|
-
</
|
|
69
|
+
</button>
|
|
67
70
|
</div>
|
|
68
71
|
</div>
|
|
69
72
|
|
|
@@ -79,9 +82,6 @@ const { hubs: hubsEnabled } = useFeatures();
|
|
|
79
82
|
<NuxtLink :to="(hub as Record<string, unknown>).source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`" class="cpub-related-hub-name">{{ hub.name }}</NuxtLink>
|
|
80
83
|
<div class="cpub-related-hub-members">{{ hub.memberCount ?? 0 }} members</div>
|
|
81
84
|
</div>
|
|
82
|
-
<button class="cpub-btn-join-sm">
|
|
83
|
-
<i class="fa-solid fa-plus" style="font-size: 8px"></i> Join
|
|
84
|
-
</button>
|
|
85
85
|
</div>
|
|
86
86
|
</div>
|
|
87
87
|
</div>
|
|
@@ -104,7 +104,8 @@ const { hubs: hubsEnabled } = useFeatures();
|
|
|
104
104
|
|
|
105
105
|
.cpub-pop-search-item {
|
|
106
106
|
display: flex; align-items: center; gap: 8px; padding: 7px 0;
|
|
107
|
-
border-bottom: var(--border-width-default) solid var(--border2);
|
|
107
|
+
border: 0; border-bottom: var(--border-width-default) solid var(--border2);
|
|
108
|
+
width: 100%; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer;
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
.cpub-pop-search-item:last-child { border-bottom: none; }
|
|
@@ -146,6 +147,7 @@ const { hubs: hubsEnabled } = useFeatures();
|
|
|
146
147
|
.cpub-cat-cell {
|
|
147
148
|
background: var(--surface2); border: var(--border-width-default) solid var(--border); padding: 10px;
|
|
148
149
|
cursor: pointer; transition: border-color 0.15s, background 0.15s; text-align: center;
|
|
150
|
+
width: 100%; color: inherit; font: inherit; display: block;
|
|
149
151
|
}
|
|
150
152
|
|
|
151
153
|
.cpub-cat-cell:hover { border-color: var(--accent); background: var(--surface3); }
|
|
@@ -170,13 +172,4 @@ const { hubs: hubsEnabled } = useFeatures();
|
|
|
170
172
|
|
|
171
173
|
.cpub-related-hub-name:hover { color: var(--accent); }
|
|
172
174
|
.cpub-related-hub-members { font-size: 10px; font-family: var(--font-mono); color: var(--text-faint); }
|
|
173
|
-
|
|
174
|
-
.cpub-btn-join-sm {
|
|
175
|
-
font-size: 10px; font-family: var(--font-mono); padding: 3px 8px;
|
|
176
|
-
border: var(--border-width-default) solid var(--border); background: var(--green-bg); color: var(--green);
|
|
177
|
-
cursor: pointer; flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px;
|
|
178
|
-
box-shadow: var(--shadow-sm); transition: all 0.15s;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
.cpub-btn-join-sm:hover { box-shadow: var(--shadow-sm); }
|
|
182
175
|
</style>
|