diff --git a/.env.example b/.env.example index 0754ce5..2fefd06 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ RESEND_FROM_EMAIL=noreply@ghostguild.org # Slack Integration SLACK_WEBHOOK_URL=your-slack-webhook-url SLACK_OAUTH_TOKEN=your-slack-oauth-token +# AdminGhost bot token — used for admin-only channel creation. Falls back to SLACK_BOT_TOKEN if unset. +SLACK_ADMIN_BOT_TOKEN=xoxb-adminghost-token # JWT Secret for authentication JWT_SECRET=your-jwt-secret-key-change-this-in-production diff --git a/app/components/BoardPostCard.vue b/app/components/BoardPostCard.vue index 9339e56..8e08375 100644 --- a/app/components/BoardPostCard.vue +++ b/app/components/BoardPostCard.vue @@ -1,7 +1,7 @@ @@ -56,11 +74,47 @@ const props = defineProps({ post: { type: Object, required: true }, channels: { type: Array, default: () => [] }, + tags: { type: Array, default: () => [] }, editable: { type: Boolean, default: false }, }) defineEmits(['edit', 'delete']) +const capitalizeAvatar = (str) => { + if (str.toLowerCase() === 'wtf') return 'WTF' + return str + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join('-') +} + +const authorAvatar = computed(() => { + const a = props.post.author?.avatar + if (!a) return null + return `/ghosties/Ghost-${capitalizeAvatar(a)}.png` +}) + +const slackHandle = computed(() => props.post.author?.board?.slackHandle || '') + +const copied = ref(false) +const copySlackHandle = async () => { + if (!slackHandle.value) return + try { + await navigator.clipboard.writeText(`@${slackHandle.value}`) + copied.value = true + setTimeout(() => { copied.value = false }, 1500) + } catch { + // clipboard unavailable + } +} + +const tagLabelMap = computed(() => { + const map = {} + for (const t of props.tags) map[t.slug] = t.label || t.name || t.slug + return map +}) +const tagLabel = (slug) => tagLabelMap.value[slug] || slug + const hasSeeking = computed(() => !!(props.post.seeking && props.post.seeking.trim())) const hasOffering = computed(() => !!(props.post.offering && props.post.offering.trim())) @@ -71,22 +125,20 @@ const typeLabel = computed(() => { return '' }) -const typeClass = computed(() => { - if (hasSeeking.value && hasOffering.value) return 'type-both' - if (hasSeeking.value) return 'type-seeking' - if (hasOffering.value) return 'type-offering' - return '' -}) - -const slackLink = computed(() => { +const slackLinks = computed(() => { const postTags = props.post.tags || [] - if (!postTags.length) return null - const channel = props.channels.find(c => { - const slugs = c.tagSlugs || [] - return slugs.some(s => postTags.includes(s)) - }) - if (!channel || !channel.slackChannelId) return null - return `https://gammaspace.slack.com/archives/${channel.slackChannelId}` + if (!postTags.length) return [] + return props.channels + .filter((c) => { + if (!c.slackChannelId) return false + const slugs = c.tagSlugs || [] + return slugs.some((s) => postTags.includes(s)) + }) + .map((c) => ({ + id: c.slackChannelId, + name: c.slackChannelName || c.name || c.slackChannelId, + url: `https://gammaspace.slack.com/archives/${c.slackChannelId}`, + })) }) @@ -94,41 +146,26 @@ const slackLink = computed(() => { .board-post { border: 1px dashed var(--border); padding: 18px 22px; - transition: background 0.15s, border-color 0.15s; -} -.board-post:hover { background: var(--surface); - border-color: var(--border-d); + break-inside: avoid; + -webkit-column-break-inside: avoid; + page-break-inside: avoid; } .post-header { display: flex; justify-content: space-between; - align-items: center; + align-items: baseline; gap: 12px; - margin-bottom: 10px; + margin-bottom: 6px; } -.type-indicator { - display: inline-block; - font-size: 10px; - letter-spacing: 0.08em; - text-transform: uppercase; - padding: 2px 8px; - border: 1px dashed; +.post-meta { font-family: "Commit Mono", monospace; -} -.type-indicator.type-seeking { - color: var(--candle); - border-color: var(--candle-faint); -} -.type-indicator.type-offering { - color: var(--green); - border-color: var(--green); -} -.type-indicator.type-both { - color: var(--ember); - border-color: var(--ember); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-faint); } .post-actions { @@ -157,11 +194,11 @@ const slackLink = computed(() => { .post-title { font-family: "Brygada 1918", serif; - font-size: 20px; + font-size: 19px; font-weight: 500; color: var(--text-bright); - margin-bottom: 10px; - line-height: 1.25; + margin: 0 0 12px; + line-height: 1.2; } .post-block { @@ -205,9 +242,9 @@ const slackLink = computed(() => { .post-footer { display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; + flex-direction: column; + align-items: flex-start; + gap: 8px; margin-top: 14px; padding-top: 10px; border-top: 1px dashed var(--border); @@ -216,13 +253,13 @@ const slackLink = computed(() => { .author { display: flex; align-items: center; - gap: 8px; + flex-wrap: wrap; + gap: 6px 8px; } .author-avatar { width: 20px; height: 20px; object-fit: cover; - border: 1px dashed var(--border); } .avatar-placeholder { background: var(--surface); @@ -232,7 +269,66 @@ const slackLink = computed(() => { color: var(--text-dim); font-family: "Commit Mono", monospace; } +.slack-handle-wrap { + display: inline-flex; + align-items: baseline; + gap: 6px; +} +.slack-handle { + font-size: 11px; + color: var(--text-faint); + font-family: "Commit Mono", monospace; + background: transparent; + border: none; + padding: 0; + cursor: pointer; +} +.slack-handle:hover { + color: var(--candle); +} +.copy-link { + font-size: 11px; + font-family: "Commit Mono", monospace; + color: var(--candle); + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-decoration: underline; +} +.copy-link:hover { + color: var(--candle-dim); +} +.copy-link.copied { + color: var(--candle); + text-decoration: none; +} +.slack-menu { + position: relative; +} +.slack-menu > summary { + list-style: none; + cursor: pointer; +} +.slack-menu > summary::-webkit-details-marker { + display: none; +} +.slack-menu-list { + position: absolute; + right: 0; + top: 100%; + margin-top: 6px; + padding: 6px 10px; + list-style: none; + background: var(--surface); + border: 1px dashed var(--border); + display: flex; + flex-direction: column; + gap: 4px; + white-space: nowrap; + z-index: 10; +} .slack-link { font-size: 11px; font-family: "Commit Mono", monospace; diff --git a/app/components/BoardPostForm.vue b/app/components/BoardPostForm.vue index 51b3821..84b81de 100644 --- a/app/components/BoardPostForm.vue +++ b/app/components/BoardPostForm.vue @@ -2,6 +2,7 @@

{{ isEdit ? 'Edit post' : 'New post' }}

+

Fill in Seeking or Offering (or both).

@@ -13,35 +14,34 @@ maxlength="120" placeholder="Short summary" > -
{{ form.title.length }}/120
+
+ +
+
+ +