Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default {
nav: [
{ text: 'Home', link: '/' },
{ text: 'About', link: '/about' },
{ text: 'Groups', link: '/groups' },
{ text: 'Events', link: '/events/' }
],
socialLinks: [
Expand Down
17 changes: 5 additions & 12 deletions docs/.vitepress/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,15 @@
"path": "/blog/post2",
"title": "Post 2"
},
{
"name": "January 14, 2025",
"description": "Getting started with NixOS",
"path": "/events/01142025",
"title": "January 14, 2025"
},
{
"name": "Post 1",
"description": "My first post",
"path": "/events/12102024",
"title": "Post 1"
},
{
"name": "BSDG Events",
"description": "Boise Software Developers Group Events",
"path": "/events/index",
"title": "BSDG Events"
},
{
"name": "index",
"description": "",
"path": "/groups/index"
}
]
155 changes: 155 additions & 0 deletions docs/.vitepress/theme/components/GroupCards.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<script setup lang="ts">
type GroupMeta = {
fullName: string
shortName?: string
organizers?: string[]
description?: string
discordRole?: string
// Optional extras you might add later:
logo?: string
tags?: string[]
}

type GroupItem = GroupMeta & {
slug: string // e.g., "bsdq"
href: string // "/groups/bsdq/"
id: string // stable key
hasLogo: boolean
}

const modules = import.meta.glob('/**/groups/**/_group.json', {
eager: true,
import: 'default'
}) as Record<string, GroupMeta>

// Turn file path → group slug (folder name)
function toSlug(filePath: string): string {
// e.g., "/.../groups/bsdq/_group.json" → "bsdq"
const parts = filePath.split('/')
const idx = parts.lastIndexOf('groups')
return idx >= 0 && parts.length > idx + 1 ? parts[idx + 1] : ''
}

// Build the list
const groups: GroupItem[] = Object.entries(modules)
.map(([path, meta]) => {
const slug = toSlug(path)
const href = slug ? `/groups/${slug}/` : '#'
const id = `${slug}::${path}`
const hasLogo = !!meta.logo
return {
slug,
href,
id,
hasLogo,
...meta
}
})
// Filter out malformed entries (missing slug or fullName)
.filter(g => g.slug && g.fullName)
// Example: sort by full name
.sort((a, b) => a.fullName.localeCompare(b.fullName))
</script>

<template>
<div class="group-grid">
<a
v-for="g in groups"
:key="g.id"
class="group-card"
:href="g.href"
>
<div class="card-header">
<div class="logo" v-if="g.hasLogo">
<img :src="g.logo" :alt="`${g.fullName} logo`" />
</div>
<div class="titles">
<h3 class="name">{{ g.fullName }}</h3>
<p v-if="g.shortName" class="short">{{ g.shortName }}</p>
</div>
</div>

<p v-if="g.description" class="desc">{{ g.description }}</p>

<div class="meta">
<span v-if="g.organizers?.length" class="chip">
👤 {{ g.organizers.join(', ') }}
</span>
<span v-if="g.discordRole" class="chip">
💬 Discord: {{ g.discordRole }}
</span>
</div>
</a>
</div>
</template>

<style scoped>
.group-grid {
display: grid;
grid-template-columns: repeat( auto-fit, minmax(260px, 1fr) );
gap: 1rem;
}
.group-card {
display: flex;
flex-direction: column;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 1rem;
text-decoration: none;
color: inherit;
background: var(--vp-c-bg-soft);
transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease;
}
.group-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,.08);
border-color: var(--vp-c-brand-1);
}
.card-header {
display: flex;
gap: .75rem;
align-items: center;
margin-bottom: .5rem;
}
.logo {
width: 44px;
height: 44px;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
background: #fff;
flex: 0 0 auto;
}
.logo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.titles .name {
font-size: 1.05rem;
margin: 0;
}
.titles .short {
margin: 0;
opacity: .7;
font-size: .9rem;
}
.desc {
margin: .5rem 0 .75rem;
opacity: .9;
line-height: 1.4;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: .5rem;
margin-top: auto;
}
.chip {
border: 1px solid var(--vp-c-divider);
padding: .25rem .5rem;
border-radius: 999px;
font-size: .85rem;
background: var(--vp-c-bg);
}
</style>
48 changes: 48 additions & 0 deletions docs/.vitepress/theme/components/MeetingsCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script setup>
const props = defineProps({
href: { type: String, required: true },
title: { type: String, required: true },
subtitle: { type: String, default: '' },
date: { type: String, default: '' },
location: { type: String, default: '' },
speakers: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] }
})
</script>

<template>
<a class="meeting-card" :href="href">
<div class="line">
<span v-if="date" class="pill">{{ date }}</span>
<span class="title">{{ title }}</span>
<span v-if="location" class="muted">· {{ location }}</span>
<span v-if="speakers.length" class="muted">· {{ speakers.join(', ') }}</span>
</div>
<div v-if="subtitle" class="subtitle">{{ subtitle }}</div>
<div v-if="tags.length" class="tags">
<span v-for="t in tags" :key="t" class="tag">#{{ t }}</span>
</div>
</a>
</template>

<style scoped>
.meeting-card {
display:block; padding:.65rem .8rem;
border:1px solid var(--vp-c-divider); border-radius:10px;
background:var(--vp-c-bg-soft); text-decoration:none; color:inherit;
transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease, background .12s ease;
}
.meeting-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0,0,0,.07);
border-color: var(--vp-c-brand-1);
background: var(--vp-c-bg);
}
.line { display:flex; align-items:baseline; gap:.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.pill { font-size:.75rem; border:1px solid var(--vp-c-divider); border-radius:999px; padding:.1rem .5rem; line-height:1.2; background:var(--vp-c-bg); flex:0 0 auto; }
.title { font-weight:600; overflow:hidden; text-overflow:ellipsis; }
.muted { opacity:.7; overflow:hidden; text-overflow:ellipsis; }
.subtitle { margin-top:.2rem; opacity:.9; overflow:hidden; text-overflow:ellipsis; display:-webkit-box; -webkit-line-clamp:1; -webkit-box-orient:vertical; }
.tags { margin-top:.3rem; display:flex; flex-wrap:wrap; gap:.35rem; }
.tag { font-size:.75rem; opacity:.85; }
</style>
127 changes: 127 additions & 0 deletions docs/.vitepress/theme/components/MeetingsList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vitepress'
import MeetingsCard from './MeetingsCard.vue'

const props = defineProps({
// "./meetings" or "/groups/<group>/meetings"
path: { type: String, default: '' },
// 0 = no limit
limit: { type: Number, default: 0 }
})

/* ---------- discover meeting pages ---------- */
function merge(...objs) { return objs.reduce((a, o) => ({ ...a, ...o }), {}) }

// Import the full MD modules; we'll read named export __pageData off them
const m1 = import.meta.glob('/groups/*/meetings/*.md', { eager: true })
const m2 = import.meta.glob('/docs/groups/*/meetings/*.md', { eager: true })
const m3 = import.meta.glob('../../groups/*/meetings/*.md', { eager: true })
const modules = merge(m1, m2, m3)

/* ---------- helpers ---------- */
function fileToSitePath(file) {
return file.replace(/^.*\/groups\//, '/groups/').replace(/\.md$/, '')
}

// Support YYYY-MM-DD, YYYYMMDD, or MMDDYYYY filenames
function parseDateFromPath(p) {
const slug = p.split('/').pop() || '' // e.g., "2025-08-31"
// YYYY-MM-DD
let m = slug.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (m) return toUtcDate(m[1], m[2], m[3])
// YYYYMMDD
m = slug.match(/^(\d{4})(\d{2})(\d{2})$/)
if (m && Number(m[1]) > 1900) return toUtcDate(m[1], m[2], m[3])
// MMDDYYYY
m = slug.match(/^(\d{2})(\d{2})(\d{4})$/)
if (m) return toUtcDate(m[3], m[1], m[2])
return null
}
function toUtcDate(y, mm, dd) {
const dt = new Date(`${y}-${mm}-${dd}T00:00:00Z`)
return isNaN(dt.getTime()) ? null : dt
}
function fmtDate(dt) {
try {
return new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: '2-digit' }).format(dt)
} catch { return '' }
}

/* base path resolution (handles "./meetings") */
const route = useRoute()
const norm = (p = '') => p ? (p.startsWith('/') ? p : `/${p}`).replace(/\/$/, '') : ''
function resolveBasePath(propPath) {
if (!propPath) return ''
const currentDir = route.path.replace(/\/[^/]*$/, '') // drop trailing page segment
if (propPath.startsWith('/')) return norm(propPath)
return norm(`${currentDir}/${propPath.replace(/^\.\//, '')}`)
}

/* ---------- build items from __pageData ---------- */
const allItems = Object.entries(modules).map(([file, mod]) => {
const href = fileToSitePath(file)
const data = mod?.__pageData ?? mod?.default?.__pageData ?? {}
const fm = data.frontmatter ?? {}

const dt = parseDateFromPath(href)
const date = dt ? fmtDate(dt) : ''

const segs = href.split('/')
const fileName = segs[segs.length - 1]

const title = fm.title || data.title || fileName
const subtitle = fm.subtitle || fm.summary || fm.description || ''
const location = fm.location || ''
const speakers = Array.isArray(fm.speakers) ? fm.speakers
: (fm.speakers ? String(fm.speakers).split(',').map(s => s.trim()).filter(Boolean) : [])
const tags = Array.isArray(fm.tags) ? fm.tags
: (fm.tags ? String(fm.tags).split(',').map(t => t.trim()).filter(Boolean) : [])

return {
href, title, subtitle, date, location, speakers, tags,
sortKey: dt ? dt.getTime() : 0
}
}).sort((a, b) => b.sortKey - a.sortKey || b.href.localeCompare(a.href))

const base = computed(() => resolveBasePath(props.path))
const items = computed(() => {
let list = allItems
const b = base.value
if (b) list = list.filter(({ href }) => href === b || href.startsWith(`${b}/`))
if (props.limit > 0) list = list.slice(0, props.limit)
return list
})
</script>

<template>
<div v-if="!items.length" class="meetings-empty">
<p>No meetings found.</p>
</div>
<div v-else class="meetings-list">
<MeetingsCard
v-for="m in items"
:key="m.href"
:href="m.href"
:title="m.title"
:subtitle="m.subtitle"
:date="m.date"
:location="m.location"
:speakers="m.speakers"
:tags="m.tags"
/>
</div>
</template>

<style scoped>
.meetings-empty {
padding: .75rem;
border: 1px dashed var(--vp-c-divider);
border-radius: 8px;
}
.meetings-list {
display: flex;
flex-direction: column; /* rows */
gap: .75rem;
}
</style>
7 changes: 7 additions & 0 deletions docs/.vitepress/theme/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ import defaultTheme from 'vitepress/theme'
import './index.css'

import LinksList from './components/LinksList.vue'
import GroupCards from './components/GroupCards.vue'
import MeetingsList from './components/MeetingsList.vue'



export default {
...defaultTheme,
enhanceApp({ app, router, siteData }) {
app.component('LinksList', LinksList)
app.component('GroupCards', GroupCards)
app.component('MeetingsList', MeetingsList)



}
}
2 changes: 1 addition & 1 deletion docs/about.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ These meetings provide great opportunities for those who would like to learn abo

Meetings are normally scheduled for the 2nd Tuesday of each month, 6:00 at Boise CodeWorks. Check the [BSDG Meetup page](/events/index.html) for information about the next meeting.

If you are interested in presenting a technology of interest to you at a meeting, visit the [BSDG discord channel](https://discord.gg/FILL_ME_IN) and post information about the topic you’d like to present!
If you are interested in presenting a technology of interest to you at a meeting of any of our supported user groups, submit a session directly on [sessionize](https://sessionize.com/bsdg) and we will review your submission.

BSDG has operated as an informal organization since its start back in the late 1990s. In 2024, it formally organized as an Idaho Nonprofit entity and a Federal 501(c)(3) non-profit organization. In that process, it has also assumed organizational control of [Boise Code Camp](https://boisecodecamp.com), a yearly event held in the spring. BSDG helps to organize other events to serve local area developers.

Expand Down
Loading