Skip to content
Draft
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/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default defineAppConfig({
{ label: 'Plugins', sections: ['plugins'], link: 'section' as const },
{ label: 'Examples', sections: ['examples'], link: 'section' as const },
{ label: 'Playground', to: '/play' },
{ label: 'Ecosystem', to: '/ecosystem' },
],
},

Expand Down
176 changes: 176 additions & 0 deletions docs/app/components/prose/EcosystemCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
title: string
icon?: string
color?: string
/** "owner/name", powers the repo link and, unless `author` is set, the avatar. Omit for a private repo. */
repo?: string
/** GitHub username or org, for the avatar and profile link when there's no public `repo` to derive it from. */
author?: string
/** npm package name, without the registry URL. */
npm?: string
/** External site when it isn't the GitHub repo (marketplace, docs, chrome store). */
site?: string
/** Overrides the GitHub destination with a deeper link (a PR or issue) instead of the repo root. */
to?: string
badge?: string
/** Feature: larger card with a hover glow (Official). Compact: dense grid (packages). Showcase: full image, no footer (Built with Comark). */
variant?: 'feature' | 'compact' | 'showcase'
/** Showcase only: a 16:9 screenshot rendered above the title. */
image?: string
}>(),
{
variant: 'compact',
color: 'var(--ui-text-highlighted)',
}
)

const badgeColor = computed(() => (props.badge === 'Official' ? 'primary' : 'neutral'))

// A GitHub profile is public even when its repo is private, so `author` doesn't require `repo`.
const author = computed(() => props.author ?? props.repo?.split('/')[0])
const authorUrl = computed(() => (author.value ? `https://github.com/${author.value}` : undefined))
const avatarUrl = computed(() => (author.value ? `https://github.com/${author.value}.png?size=64` : undefined))
const siteLabel = computed(() => props.site?.replace(/^https?:\/\//, '').replace(/\/$/, ''))

// The footer GitHub icon always points at the repo root, independent of `to`.
const repoUrl = computed(() => (props.repo ? `https://github.com/${props.repo}` : undefined))
const npmUrl = computed(() => (props.npm ? `https://www.npmjs.com/package/${props.npm}` : undefined))

// Whole-card click target: `to` overrides (a PR, issue, or a showcase's live site),
// otherwise the repo, otherwise an external site (private repos, marketplaces).
const primaryTo = computed(() => props.to ?? repoUrl.value ?? props.site)

const isFeature = computed(() => props.variant === 'feature')
const isShowcase = computed(() => props.variant === 'showcase')
const iconClass = computed(() =>
isFeature.value
? 'mb-2 size-8 text-(--eco-color) grayscale opacity-60 transition duration-200 group-hover:grayscale-0 group-hover:opacity-100'
: 'size-6 text-(--eco-color)'
)
const titleClass = computed(() =>
isShowcase.value ? 'text-base font-semibold text-highlighted' : 'text-sm font-medium text-highlighted'
)
const descriptionClass = computed(() => {
if (isShowcase.value) return 'mt-1.5 text-sm leading-relaxed text-toned'
if (isFeature.value) return 'mt-1 text-sm leading-relaxed text-toned'
return 'mt-1 text-sm leading-relaxed line-clamp-2 text-muted'
})
</script>

<template>
<UPageCard
:to="primaryTo"
target="_blank"
:icon="isShowcase ? undefined : icon"
:variant="isFeature ? 'subtle' : 'soft'"
:spotlight="isFeature"
:class="
isFeature
? 'ecosystem-card group transition duration-200 hover:bg-default hover:ring-(--eco-ring) hover:shadow-[0_12px_32px_-20px_var(--eco-glow)]'
: 'ecosystem-card'
"
:ui="{
header: 'relative overflow-hidden rounded-md aspect-video mb-4',
leadingIcon: iconClass,
title: titleClass,
description: descriptionClass,
footer: 'pt-3 mt-3 flex items-center justify-between gap-2 relative z-1',
}"
>
<template
v-if="isShowcase"
#header
>
<img
:src="image"
:alt="`${title} screenshot`"
width="960"
height="540"
loading="lazy"
class="w-full h-full object-cover object-top"
/>
</template>

<template #title>
<span class="inline-flex items-center gap-2">
{{ title }}
<UBadge
v-if="badge"
:label="badge"
:color="badgeColor"
variant="subtle"
size="sm"
/>
</span>
</template>

<template #description>
<slot />
</template>

<template #footer>
<ULink
v-if="author"
:to="authorUrl"
target="_blank"
raw
class="relative z-1 inline-flex items-center gap-1.5 text-xs text-muted hover:text-highlighted"
>
<UAvatar
:src="avatarUrl"
:alt="`${author} on GitHub`"
size="3xs"
/>
{{ author }}
</ULink>
<span
v-else
class="relative z-1 text-xs text-muted"
>{{ siteLabel }}</span
>

<span class="relative z-1 flex items-center gap-0.5">
<UButton
v-if="repo"
icon="i-simple-icons-github"
:to="repoUrl"
target="_blank"
size="xs"
variant="ghost"
color="neutral"
:aria-label="`${title} on GitHub`"
/>
<UButton
v-if="npm"
icon="i-simple-icons-npm"
:to="npmUrl"
target="_blank"
size="xs"
variant="ghost"
color="neutral"
:aria-label="`${title} on npm`"
/>
<UButton
v-if="site && repo"
icon="i-lucide-external-link"
:to="site"
target="_blank"
size="xs"
variant="ghost"
color="neutral"
:aria-label="`${title} site`"
/>
</span>
</template>
</UPageCard>
</template>

<style scoped>
.ecosystem-card {
--eco-color: v-bind(color);
--eco-ring: color-mix(in oklab, var(--eco-color) 50%, var(--ui-border));
--eco-glow: color-mix(in oklab, var(--eco-color) 40%, transparent);
}
</style>
23 changes: 23 additions & 0 deletions docs/app/components/prose/EcosystemGrid.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script setup lang="ts">
// Plain CSS grid, no card-group defaults: ecosystem cards need a tighter gap than prose card-group.
// MDC `{cols="2"}` attribute syntax always yields a string, so accept both and coerce.
const props = withDefaults(
defineProps<{
cols?: 2 | 3 | '2' | '3'
}>(),
{
cols: 3,
}
)

const twoCol = computed(() => Number(props.cols) === 2)
</script>

<template>
<div
class="my-6 grid grid-cols-1 gap-4"
:class="twoCol ? 'sm:grid-cols-2' : 'sm:grid-cols-2 lg:grid-cols-3'"
>
<slot />
</div>
</template>
Loading