diff --git a/app/components/AppNavigation.vue b/app/components/AppNavigation.vue index f113c52..3095f8b 100644 --- a/app/components/AppNavigation.vue +++ b/app/components/AppNavigation.vue @@ -34,8 +34,13 @@ :to="item.path" :class="{ active: isActive(item.path) }" @click="handleNavigate" - >{{ item.label }} + {{ item.label }} + {{ pendingCount }} + @@ -129,7 +134,21 @@ const emit = defineEmits(["navigate"]); const route = useRoute(); const { isAuthenticated, logout } = useAuth(); +const { getPendingCount } = useConnections(); const isDev = import.meta.dev; +const pendingCount = ref(0); + +// Fetch pending connection count for authenticated users +onMounted(async () => { + if (isAuthenticated.value) { + try { + const data = await getPendingCount(); + pendingCount.value = data.count || 0; + } catch { + // Silently ignore — badge is non-critical + } + } +}); const handleNavigate = () => { if (props.isMobile) { @@ -173,7 +192,8 @@ const youItems = [ const exploreItems = [ { label: "Events", path: "/events" }, { label: "Members", path: "/members" }, - { label: "Wiki", path: "/wiki" }, + { label: "Connections", path: "/connections" }, + { label: "Wiki", path: "https://wiki.ghostguild.org" }, { label: "About", path: "/about" }, ]; @@ -278,4 +298,19 @@ const exploreItems = [ .sidebar-meta a { color: var(--candle-dim); } + +.nav-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + margin-left: 6px; + font-size: 10px; + line-height: 1; + color: var(--bg); + background: var(--candle); + border-radius: 0; +} diff --git a/app/components/CooperativeTagSelector.vue b/app/components/CooperativeTagSelector.vue new file mode 100644 index 0000000..1772c9c --- /dev/null +++ b/app/components/CooperativeTagSelector.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/app/components/CraftTagSelector.vue b/app/components/CraftTagSelector.vue new file mode 100644 index 0000000..4fe494a --- /dev/null +++ b/app/components/CraftTagSelector.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/app/components/TagSuggestModal.vue b/app/components/TagSuggestModal.vue new file mode 100644 index 0000000..bacf2b1 --- /dev/null +++ b/app/components/TagSuggestModal.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/app/composables/useConnections.js b/app/composables/useConnections.js new file mode 100644 index 0000000..6bf3833 --- /dev/null +++ b/app/composables/useConnections.js @@ -0,0 +1,32 @@ +export const useConnections = () => { + const getSuggestions = (params = {}) => + $fetch('/api/connections/suggestions', { params }) + + const getMyConnections = () => + $fetch('/api/connections') + + const requestConnection = (recipientId) => + $fetch('/api/connections', { method: 'POST', body: { recipientId } }) + + const confirmConnection = (id) => + $fetch(`/api/connections/${id}/confirm`, { method: 'POST' }) + + const hideConnection = (id) => + $fetch(`/api/connections/${id}/hide`, { method: 'POST' }) + + const withdrawConnection = (id) => + $fetch(`/api/connections/${id}/withdraw`, { method: 'POST' }) + + const getPendingCount = () => + $fetch('/api/connections/pending-count') + + return { + getSuggestions, + getMyConnections, + requestConnection, + confirmConnection, + hideConnection, + withdrawConnection, + getPendingCount, + } +} diff --git a/app/pages/connections.vue b/app/pages/connections.vue new file mode 100644 index 0000000..e295daf --- /dev/null +++ b/app/pages/connections.vue @@ -0,0 +1,823 @@ + + + + + diff --git a/app/pages/member/profile.vue b/app/pages/member/profile.vue index 2c40b27..3f68cc9 100644 --- a/app/pages/member/profile.vue +++ b/app/pages/member/profile.vue @@ -140,45 +140,16 @@ - - - -
-
- +
- - What I Do + - -
-
- - -
- -
- - - -
-
- - +
@@ -206,11 +177,34 @@
- + + +
+ + + +
+ +
+ + +
+ {{ formData.communityConnectionsDetails?.length || 0 }} / 300 +
+
@@ -221,47 +215,11 @@
-
-
- - -
- Suggested from your offerings: - {{ tag }} -
-
- -
- -
- -
-
- +
@@ -270,7 +228,7 @@
@@ -279,13 +237,13 @@
- {{ formData.peerSupportMessage?.length || 0 }} / 200 + {{ formData.communityConnectionsPersonalMessage?.length || 0 }} / 200
@@ -324,11 +282,11 @@
- Peer support requests + Connection requests When someone wants to connect @@ -360,6 +318,9 @@
+ + +
@@ -389,6 +350,20 @@ const availableGhosts = [ { value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" }, ]; +// Fetch tags and split into pools +const { data: tagsData } = await useFetch("/api/tags"); + +const craftTags = computed(() => + (tagsData.value?.tags || []).filter((t) => t.pool === "craft"), +); +const cooperativeTags = computed(() => + (tagsData.value?.tags || []).filter((t) => t.pool === "cooperative"), +); + +// Tag suggest modal state +const showTagSuggestModal = ref(false); +const tagSuggestPool = ref(""); + // Form state const formData = reactive({ name: "", @@ -398,16 +373,18 @@ const formData = reactive({ studio: "", bio: "", location: "", - offering: { text: "", tags: [] }, - lookingFor: { text: "", tags: [] }, showInDirectory: true, - // Peer support - peerSupportEnabled: false, - peerSupportSkillTopics: [], - peerSupportSupportTopics: [], - peerSupportAvailability: "", - peerSupportMessage: "", - peerSupportSlackUsername: "", + // Craft tags + craftTags: [], + craftTagsPrivacy: "members", + // Community connections + communityConnectionsTopics: [], + communityConnectionsPrivacy: "members", + communityConnectionsDetails: "", + communityConnectionsOfferPeerSupport: false, + communityConnectionsAvailability: "", + communityConnectionsSlackHandle: "", + communityConnectionsPersonalMessage: "", // Privacy pronounsPrivacy: "members", timeZonePrivacy: "members", @@ -415,12 +392,10 @@ const formData = reactive({ studioPrivacy: "members", bioPrivacy: "members", locationPrivacy: "members", - offeringPrivacy: "members", - lookingForPrivacy: "members", // Notifications notifyEvents: true, notifyUpdates: true, - notifyPeerRequests: true, + notifyConnectionRequests: true, }); const loading = ref(false); @@ -429,47 +404,11 @@ const saveSuccess = ref(false); const saveError = ref(null); const initialData = ref(null); -// Available conversational support topics -const availableSupportTopics = [ - "Co-founder relationships", - "Burnout prevention", - "Impostor syndrome", - "Work-life boundaries", - "Conflict resolution", - "General chat & support", -]; - // Computed const hasChanges = computed(() => { return JSON.stringify(formData) !== JSON.stringify(initialData.value); }); -const suggestedSkillTopics = computed(() => { - if (!formData.offering.tags?.length) return []; - return formData.offering.tags.filter( - (t) => !formData.peerSupportSkillTopics?.includes(t), - ); -}); - -// Toggle a support topic in/out of the selection -const toggleSupportTopic = (topic) => { - const idx = formData.peerSupportSupportTopics.indexOf(topic); - if (idx >= 0) { - formData.peerSupportSupportTopics.splice(idx, 1); - } else { - formData.peerSupportSupportTopics.push(topic); - } -}; - -const addSuggestedSkillTopic = (tag) => { - if (!Array.isArray(formData.peerSupportSkillTopics)) { - formData.peerSupportSkillTopics = []; - } - if (!formData.peerSupportSkillTopics.includes(tag)) { - formData.peerSupportSkillTopics.push(tag); - } -}; - // Load member data into form const loadProfile = () => { if (memberData.value) { @@ -481,66 +420,21 @@ const loadProfile = () => { formData.bio = memberData.value.bio || ""; formData.location = memberData.value.location || ""; - // Load offering (handle both old string and new object format) - if (typeof memberData.value.offering === "string") { - formData.offering.text = memberData.value.offering; - formData.offering.tags = []; - } else if (memberData.value.offering) { - formData.offering.text = memberData.value.offering?.text || ""; - formData.offering.tags = Array.isArray(memberData.value.offering?.tags) - ? [...memberData.value.offering.tags] - : []; - } else { - formData.offering.text = ""; - formData.offering.tags = []; - } - - // Load lookingFor (handle both old string and new object format) - if (typeof memberData.value.lookingFor === "string") { - formData.lookingFor.text = memberData.value.lookingFor; - formData.lookingFor.tags = []; - } else if (memberData.value.lookingFor) { - formData.lookingFor.text = memberData.value.lookingFor?.text || ""; - formData.lookingFor.tags = Array.isArray( - memberData.value.lookingFor?.tags, - ) - ? [...memberData.value.lookingFor.tags] - : []; - } else { - formData.lookingFor.text = ""; - formData.lookingFor.tags = []; - } - formData.showInDirectory = memberData.value.showInDirectory ?? true; - // Load peer support data - if (memberData.value.peerSupport) { - formData.peerSupportEnabled = - memberData.value.peerSupport.enabled || false; - formData.peerSupportSkillTopics = Array.isArray( - memberData.value.peerSupport.skillTopics, - ) - ? [...memberData.value.peerSupport.skillTopics] - : []; - formData.peerSupportSupportTopics = Array.isArray( - memberData.value.peerSupport.supportTopics, - ) - ? [...memberData.value.peerSupport.supportTopics] - : []; - formData.peerSupportAvailability = - memberData.value.peerSupport.availability || ""; - formData.peerSupportMessage = - memberData.value.peerSupport.personalMessage || ""; - formData.peerSupportSlackUsername = - memberData.value.peerSupport.slackUsername || ""; - } else { - formData.peerSupportEnabled = false; - formData.peerSupportSkillTopics = []; - formData.peerSupportSupportTopics = []; - formData.peerSupportAvailability = ""; - formData.peerSupportMessage = ""; - formData.peerSupportSlackUsername = ""; - } + // Load craft tags + formData.craftTags = Array.isArray(memberData.value.craftTags) + ? [...memberData.value.craftTags] + : []; + + // Load community connections (with fallback to old peerSupport fields) + const cc = memberData.value.communityConnections || {}; + formData.communityConnectionsTopics = Array.isArray(cc.topics) ? [...cc.topics] : []; + formData.communityConnectionsOfferPeerSupport = cc.offerPeerSupport ?? memberData.value.peerSupport?.enabled ?? false; + formData.communityConnectionsAvailability = cc.availability || memberData.value.peerSupport?.availability || ""; + formData.communityConnectionsSlackHandle = cc.slackHandle || memberData.value.peerSupport?.slackUsername || ""; + formData.communityConnectionsPersonalMessage = cc.personalMessage || memberData.value.peerSupport?.personalMessage || ""; + formData.communityConnectionsDetails = cc.details || ""; // Load privacy settings (with defaults) const privacy = memberData.value.privacy || {}; @@ -550,14 +444,14 @@ const loadProfile = () => { formData.studioPrivacy = privacy.studio || "members"; formData.bioPrivacy = privacy.bio || "members"; formData.locationPrivacy = privacy.location || "members"; - formData.offeringPrivacy = privacy.offering || "members"; - formData.lookingForPrivacy = privacy.lookingFor || "members"; + formData.craftTagsPrivacy = privacy.craftTags || "members"; + formData.communityConnectionsPrivacy = privacy.communityConnections || "members"; // Load notification prefs const notifs = memberData.value.notifications || {}; formData.notifyEvents = notifs.events ?? true; formData.notifyUpdates = notifs.updates ?? true; - formData.notifyPeerRequests = notifs.peerRequests ?? true; + formData.notifyConnectionRequests = notifs.connectionRequests ?? notifs.peerRequests ?? true; // Store initial state for change detection initialData.value = JSON.parse(JSON.stringify(formData)); @@ -571,29 +465,30 @@ const handleSubmit = async () => { saveError.value = null; try { - // Save profile data + // Save profile data (includes craft tags + privacy + notifications) await $fetch("/api/members/profile", { method: "PATCH", body: { ...formData, + craftTags: formData.craftTags, notifications: { events: formData.notifyEvents, updates: formData.notifyUpdates, - peerRequests: formData.notifyPeerRequests, + connectionRequests: formData.notifyConnectionRequests, }, }, }); - // Save peer support data separately - await $fetch("/api/members/me/peer-support", { + // Save community connections data + await $fetch("/api/members/me/community-connections", { method: "PATCH", body: { - enabled: formData.peerSupportEnabled, - skillTopics: formData.peerSupportSkillTopics, - supportTopics: formData.peerSupportSupportTopics, - availability: formData.peerSupportAvailability, - personalMessage: formData.peerSupportMessage, - slackUsername: formData.peerSupportSlackUsername, + topics: formData.communityConnectionsTopics, + offerPeerSupport: formData.communityConnectionsOfferPeerSupport, + availability: formData.communityConnectionsAvailability, + slackHandle: formData.communityConnectionsSlackHandle, + personalMessage: formData.communityConnectionsPersonalMessage, + details: formData.communityConnectionsDetails, }, }); @@ -844,48 +739,8 @@ useHead({ margin-top: 1px; } -/* ---- CHECKBOX GRID ---- */ -.checkbox-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 3px 12px; -} - -.checkbox-item { - display: flex; - align-items: center; - gap: 5px; - font-size: 11px; - color: var(--text-dim); - cursor: pointer; - padding: 2px 0; - user-select: none; -} - -.checkbox-item .cb { - width: 13px; - height: 13px; - border: 1px dashed var(--border); - display: flex; - align-items: center; - justify-content: center; - font-size: 9px; - color: transparent; - flex-shrink: 0; -} - -.checkbox-item.checked .cb { - border-color: var(--candle); - border-style: solid; - color: var(--candle); -} - -.checkbox-item:hover { - color: var(--text); -} - -/* ---- PEER SUPPORT PANEL ---- */ -.peer-panel { +/* ---- CONNECTIONS PANEL ---- */ +.connections-panel { border: 1px dashed var(--border); padding: 12px 14px; margin-top: 4px; @@ -893,19 +748,6 @@ useHead({ background: var(--surface); } -.peer-panel .suggested { - font-size: 10px; - color: var(--text-faint); - margin-top: 4px; -} - -.peer-panel .suggested a { - color: var(--candle); - text-decoration: underline; - cursor: pointer; - margin-left: 4px; -} - /* ---- DISABLED BUTTON ---- */ .btn:disabled { opacity: 0.4; @@ -962,10 +804,6 @@ useHead({ grid-template-columns: 1fr; } - .checkbox-grid { - grid-template-columns: 1fr; - } - .profile-col-left .profile-col-inset, .profile-col-right .profile-col-inset { padding-left: 16px; diff --git a/app/pages/members/[id].vue b/app/pages/members/[id].vue index 6b4e254..0b47108 100644 --- a/app/pages/members/[id].vue +++ b/app/pages/members/[id].vue @@ -60,40 +60,42 @@

- -
- -
+ +
+ +
{{ tag }}{{ tagLabel('craft', tag) }}
-

+

{{ member.offering.text }}

- +
- -
+ +
{{ tag }} + {{ stateLabel(topic.state) }} + {{ tagLabel('cooperative', topic.tagSlug || topic) }} +
-

+

+ {{ member.communityConnections.details }} +

+

{{ member.lookingFor.text }}

@@ -150,10 +152,10 @@
- -
+ +
-
+
Skills:
-
+
Topics:
-

- {{ member.peerSupport.availability }} +

+ {{ peerAvailability }}

@@ -233,6 +235,15 @@ const circleLabels = { practitioner: "Practitioner", }; +// State display text mapping +const stateLabels = { + help: "Can help", + interested: "Interested", + seeking: "Need help", +}; + +const stateLabel = (state) => stateLabels[state] || state || ""; + const getInitials = (name) => { if (!name) return "?"; return name @@ -246,6 +257,11 @@ const getInitials = (name) => { // Fetch member data — no await so the component renders immediately (no Suspense) const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`); +// Fetch tags for slug-to-label lookup +const { data: tagsData } = useFetch("/api/tags", { + default: () => ({ tags: [] }), +}); + // Fetch public activity const { data: activityData } = useFetch(`/api/members/${id}/activity`, { params: { limit: 5 }, @@ -267,6 +283,56 @@ const formatRelativeDate = (date) => { } const member = computed(() => data.value?.member || null); +// Tag label lookup +const tagLabel = (pool, slug) => { + const tags = tagsData.value?.tags || []; + const found = tags.find((t) => t.slug === slug && t.pool === pool); + return found ? found.label : slug; +}; + +// Craft tags display: new field, falling back to offering.tags +const craftTagsDisplay = computed(() => { + if (!member.value) return []; + if (member.value.craftTags && member.value.craftTags.length > 0) { + return member.value.craftTags; + } + return member.value.offering?.tags || []; +}); + +// Connection topics display: new field, falling back to lookingFor.tags +const connectionTopicsDisplay = computed(() => { + if (!member.value) return []; + if ( + member.value.communityConnections?.topics && + member.value.communityConnections.topics.length > 0 + ) { + return member.value.communityConnections.topics; + } + if (member.value.lookingFor?.tags && member.value.lookingFor.tags.length > 0) { + return member.value.lookingFor.tags.map((tag) => ({ tagSlug: tag, state: null })); + } + return []; +}); + +// Peer support: check both new communityConnections and old peerSupport +const showPeerSupport = computed(() => { + if (!member.value) return false; + return ( + member.value.communityConnections?.offerPeerSupport || + member.value.peerSupport?.enabled + ); +}); + +// Peer availability: prefer new field, fall back to old +const peerAvailability = computed(() => { + if (!member.value) return ""; + return ( + member.value.communityConnections?.availability || + member.value.peerSupport?.availability || + "" + ); +}); + const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => ""); watch( member, @@ -442,7 +508,8 @@ useHead({ } .offering-text, -.looking-text { +.looking-text, +.connection-details { margin-top: 8px; } @@ -462,6 +529,19 @@ useHead({ white-space: nowrap; } +.connection-pill { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.connection-state { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-faint); +} + /* ---- SOCIAL LINKS ---- */ .social-links { display: flex; diff --git a/app/pages/members/index.vue b/app/pages/members/index.vue index a7d4658..dcf0cfd 100644 --- a/app/pages/members/index.vue +++ b/app/pages/members/index.vue @@ -41,64 +41,64 @@ >
- +
- Skills: + Craft:
- +
Topics:
@@ -117,16 +117,16 @@ Offering Peer Support - - {{ skill }} - + + {{ craftTagLabel(slug) }} + - - {{ topic }} - + + {{ connectionTagLabel(slug) }} +