create-ginko 0.6.2 → 0.6.3
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/CHANGELOG.md +4 -0
- package/package.json +1 -1
- package/templates/blog/app/components/block/Cards.vue +5 -3
- package/templates/blog/app/components/blog/PostList.vue +4 -3
- package/templates/blog/app/components/people/List.vue +4 -3
- package/templates/blog/app/pages/about/[slug].vue +1 -1
- package/templates/blog/app/pages/news/index.vue +2 -2
- package/templates/blog/app/pages/news/tags/[tag].vue +1 -1
- package/templates/blog/app/pages/team.vue +1 -1
- package/templates/blog/nuxt.config.ts +2 -0
- package/templates/blog/public/favicon.svg +1 -0
- package/templates/company/app/components/block/Cards.vue +5 -3
- package/templates/company/app/components/blog/PostList.vue +4 -3
- package/templates/company/app/components/people/List.vue +4 -3
- package/templates/company/app/pages/events/index.vue +1 -1
- package/templates/company/app/pages/news/index.vue +2 -2
- package/templates/company/app/pages/news/tags/[tag].vue +1 -1
- package/templates/company/app/pages/services/index.vue +1 -1
- package/templates/company/app/pages/team.vue +1 -1
- package/templates/company/nuxt.config.ts +2 -0
- package/templates/company/public/favicon.svg +1 -0
- package/templates/docs/content/docs/2.getting-started.de.md +2 -0
- package/templates/docs/content/docs/2.getting-started.md +2 -0
- package/templates/events/app/components/block/Cards.vue +5 -3
- package/templates/events/app/components/block/Gallery.vue +3 -3
- package/templates/events/app/pages/events/index.vue +1 -1
- package/templates/events/nuxt.config.ts +2 -0
- package/templates/events/public/favicon.svg +1 -0
- package/templates/local-business/app/components/block/Cards.vue +5 -3
- package/templates/local-business/app/components/block/Gallery.vue +3 -3
- package/templates/local-business/app/components/people/List.vue +4 -3
- package/templates/local-business/app/pages/services/index.vue +1 -1
- package/templates/local-business/app/pages/team.vue +1 -1
- package/templates/local-business/nuxt.config.ts +2 -0
- package/templates/local-business/public/favicon.svg +1 -0
- package/templates/nonprofit/app/components/block/Cards.vue +5 -3
- package/templates/nonprofit/app/components/blog/PostList.vue +4 -3
- package/templates/nonprofit/app/components/people/List.vue +4 -3
- package/templates/nonprofit/app/pages/events/index.vue +1 -1
- package/templates/nonprofit/app/pages/news/index.vue +2 -2
- package/templates/nonprofit/app/pages/news/tags/[tag].vue +1 -1
- package/templates/nonprofit/app/pages/projects/index.vue +1 -1
- package/templates/nonprofit/app/pages/team.vue +1 -1
- package/templates/nonprofit/nuxt.config.ts +2 -0
- package/templates/nonprofit/public/favicon.svg +1 -0
- package/templates/portfolio/app/components/block/Cards.vue +5 -3
- package/templates/portfolio/app/components/block/Gallery.vue +3 -3
- package/templates/portfolio/app/components/blog/PostList.vue +4 -3
- package/templates/portfolio/app/components/people/List.vue +4 -3
- package/templates/portfolio/app/pages/news/index.vue +2 -2
- package/templates/portfolio/app/pages/news/tags/[tag].vue +1 -1
- package/templates/portfolio/app/pages/projects/index.vue +1 -1
- package/templates/portfolio/app/pages/team.vue +1 -1
- package/templates/portfolio/nuxt.config.ts +2 -0
- package/templates/portfolio/public/favicon.svg +1 -0
- package/templates/restaurant/app/components/block/Cards.vue +5 -3
- package/templates/restaurant/app/components/block/Gallery.vue +3 -3
- package/templates/restaurant/app/pages/events/index.vue +1 -1
- package/templates/restaurant/app/pages/gallery.vue +1 -1
- package/templates/restaurant/nuxt.config.ts +2 -0
- package/templates/restaurant/public/favicon.svg +1 -0
- package/templates/saas/app/components/block/Cards.vue +5 -3
- package/templates/saas/app/components/blog/PostList.vue +4 -3
- package/templates/saas/app/pages/changelog.vue +1 -1
- package/templates/saas/app/pages/news/index.vue +2 -2
- package/templates/saas/app/pages/news/tags/[tag].vue +1 -1
- package/templates/saas/nuxt.config.ts +2 -0
- package/templates/saas/public/favicon.svg +1 -0
- package/templates/starter/app/components/block/Cards.vue +5 -3
- package/templates/starter/nuxt.config.ts +2 -0
- package/templates/starter/public/favicon.svg +1 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -16,8 +16,9 @@ export interface Card {
|
|
|
16
16
|
/** Two to four cards per row; the whole card is one link (the title's link stretches over it), marked by an arrow.
|
|
17
17
|
* Photos are 3:2. `headingLevel`: h3 under a section's h2 (default), h2 when the cards follow the page's h1.
|
|
18
18
|
* `compact`: on phones (below `sm`) each card is one row, a square photo of 96 px at the start next to the meta line
|
|
19
|
-
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
-
|
|
19
|
+
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
+
* `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
21
|
+
const props = withDefaults(defineProps<{ items: Card[], columns?: 2 | 3 | 4, headingLevel?: 'h2' | 'h3', compact?: boolean, eager?: boolean }>(), { columns: 3, headingLevel: 'h3' })
|
|
21
22
|
// Never an empty column at lg: fewer items than `columns` take that many columns (two at least; one item stays as is).
|
|
22
23
|
const lgColumns = computed(() => (props.items.length > 1 ? Math.min(props.columns, Math.max(2, props.items.length)) : props.columns))
|
|
23
24
|
const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw, ${props.compact ? '96px' : '100vw'}`)
|
|
@@ -29,7 +30,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
29
30
|
:class="{ 'lg:grid-cols-3': lgColumns === 3, 'lg:grid-cols-4': lgColumns === 4, 'max-sm:gap-3': compact }"
|
|
30
31
|
>
|
|
31
32
|
<li
|
|
32
|
-
v-for="item in items"
|
|
33
|
+
v-for="(item, i) in items"
|
|
33
34
|
:key="item.key"
|
|
34
35
|
class="group relative flex overflow-hidden rounded-block border bg-card transition-[color,background-color,border-color] duration-150 hover:bg-muted has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-3 has-[a:focus-visible]:outline-ring"
|
|
35
36
|
:class="compact ? 'max-sm:items-center sm:flex-col' : 'flex-col'"
|
|
@@ -38,6 +39,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
38
39
|
v-if="item.image"
|
|
39
40
|
:image="item.image"
|
|
40
41
|
:sizes="sizes"
|
|
42
|
+
:eager="eager && i === 0"
|
|
41
43
|
class="aspect-3/2 w-full bg-muted object-cover"
|
|
42
44
|
:class="{ 'max-sm:m-3 max-sm:me-0 max-sm:aspect-square max-sm:size-24 max-sm:shrink-0 max-sm:rounded-md': compact }"
|
|
43
45
|
/>
|
|
@@ -4,18 +4,19 @@ import type { BlogPost } from '~/composables/useBlog'
|
|
|
4
4
|
|
|
5
5
|
/** Posts as rows divided by lines: date and authors, title (the link), summary, and the cover (3:2) at the end of the
|
|
6
6
|
* row from `sm` up, above the text on phones. A post without a cover is a row without an image, not a gap. `images` false
|
|
7
|
-
* leaves the covers out (a teaser on a home page). */
|
|
8
|
-
withDefaults(defineProps<{ items: BlogPost[], headingLevel?: 'h2' | 'h3', images?: boolean }>(), { headingLevel: 'h2', images: true })
|
|
7
|
+
* leaves the covers out (a teaser on a home page). `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
8
|
+
withDefaults(defineProps<{ items: BlogPost[], headingLevel?: 'h2' | 'h3', images?: boolean, eager?: boolean }>(), { headingLevel: 'h2', images: true })
|
|
9
9
|
const { format, t } = useI18n('blog')
|
|
10
10
|
</script>
|
|
11
11
|
|
|
12
12
|
<template>
|
|
13
13
|
<ul class="flex flex-col border-b">
|
|
14
|
-
<li v-for="post in items" :key="post.id" class="group relative flex flex-col gap-5 border-t py-8 sm:flex-row-reverse sm:items-start sm:gap-8">
|
|
14
|
+
<li v-for="(post, i) in items" :key="post.id" class="group relative flex flex-col gap-5 border-t py-8 sm:flex-row-reverse sm:items-start sm:gap-8">
|
|
15
15
|
<GinkoImage
|
|
16
16
|
v-if="images && post.image"
|
|
17
17
|
:image="post.image"
|
|
18
18
|
sizes="(min-width: 640px) 224px, 100vw"
|
|
19
|
+
:eager="eager && i === 0"
|
|
19
20
|
class="aspect-3/2 w-full shrink-0 rounded-block bg-muted object-cover sm:w-56"
|
|
20
21
|
/>
|
|
21
22
|
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
<script setup lang="ts">
|
|
3
3
|
/** People as a grid: photo (4:5) or initials, name (a link to the person's page when people have pages), role, short
|
|
4
4
|
* bio and contact. Never a stock face for a named person: without a photo the initials stand in, on phones small and
|
|
5
|
-
* beside the name, so the bios stay close. */
|
|
6
|
-
defineProps<{ items: GinkoItem<'people'>[] }>()
|
|
5
|
+
* beside the name, so the bios stay close. `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
6
|
+
defineProps<{ items: GinkoItem<'people'>[], eager?: boolean }>()
|
|
7
7
|
const { t } = useI18n('people')
|
|
8
8
|
const locale = useGinkoLocale()
|
|
9
9
|
</script>
|
|
10
10
|
|
|
11
11
|
<template>
|
|
12
12
|
<ul class="grid gap-x-10 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
|
|
13
|
-
<li v-for="person in items" :id="person.id.split('/').pop()" :key="person.id" class="flex flex-col gap-4 break-inside-avoid">
|
|
13
|
+
<li v-for="(person, i) in items" :id="person.id.split('/').pop()" :key="person.id" class="flex flex-col gap-4 break-inside-avoid">
|
|
14
14
|
<GinkoImage
|
|
15
15
|
v-if="person.data.photo"
|
|
16
|
+
:eager="eager && i === 0"
|
|
16
17
|
:image="person.data.photo"
|
|
17
18
|
sizes="(min-width: 1024px) 384px, (min-width: 640px) 50vw, 100vw"
|
|
18
19
|
class="aspect-4/5 w-full rounded-block bg-muted object-cover"
|
|
@@ -37,7 +37,7 @@ usePageSeo(() => ({
|
|
|
37
37
|
<section aria-labelledby="author-posts" class="mx-auto max-w-page px-gutter">
|
|
38
38
|
<div class="max-w-4xl">
|
|
39
39
|
<h2 id="author-posts" class="pb-2 text-h3">{{ t('author.posts', { name: person.data.name }) }}</h2>
|
|
40
|
-
<BlogPostList v-if="written.length" :items="written" heading-level="h3" />
|
|
40
|
+
<BlogPostList v-if="written.length" :items="written" heading-level="h3" eager />
|
|
41
41
|
<p v-else class="border-t py-8 text-muted-foreground">{{ t('author.none') }}</p>
|
|
42
42
|
<NuxtLink :to="path('news')" class="mt-6 inline-flex min-h-11 items-center gap-2 font-medium text-primary underline-offset-4 hover:underline">
|
|
43
43
|
{{ t('blog.all') }}<ArrowRight class="size-4 rtl:-scale-x-100" aria-hidden="true" />
|
|
@@ -40,10 +40,10 @@ usePageSeo(() => ({
|
|
|
40
40
|
<template v-if="years.length > 1">
|
|
41
41
|
<section v-for="group in years" :key="group.year" :aria-labelledby="`year-${group.year}`" class="pt-6 first:pt-0">
|
|
42
42
|
<h2 :id="`year-${group.year}`" class="pb-2 text-h3 tabular-nums">{{ group.year }}</h2>
|
|
43
|
-
<BlogPostList :items="group.posts" heading-level="h3" />
|
|
43
|
+
<BlogPostList :items="group.posts" heading-level="h3" :eager="group === years[0]" />
|
|
44
44
|
</section>
|
|
45
45
|
</template>
|
|
46
|
-
<BlogPostList v-else-if="posts.length" :items="posts" />
|
|
46
|
+
<BlogPostList v-else-if="posts.length" :items="posts" eager />
|
|
47
47
|
<BlockEmpty v-else :title="t('empty')" path="content/posts/" />
|
|
48
48
|
<a :href="feed" class="mt-6 inline-flex min-h-11 items-center gap-2 text-small text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
|
|
49
49
|
<Rss class="size-4" aria-hidden="true" />{{ t('feed') }}
|
|
@@ -31,7 +31,7 @@ usePageSeo(() => ({ title: title.value, description: t('count', { count: tag.val
|
|
|
31
31
|
<BlockPageHeader :title="title" :meta="t('count', { count: tag.count })" />
|
|
32
32
|
<div class="mx-auto max-w-page px-gutter">
|
|
33
33
|
<div class="max-w-4xl">
|
|
34
|
-
<BlogPostList :items="tagged" />
|
|
34
|
+
<BlogPostList :items="tagged" eager />
|
|
35
35
|
<NuxtLink :to="path('news')" class="mt-6 inline-flex min-h-11 items-center gap-2 font-medium text-primary underline-offset-4 hover:underline">
|
|
36
36
|
{{ t('all') }}<ArrowRight class="size-4 rtl:-scale-x-100" aria-hidden="true" />
|
|
37
37
|
</NuxtLink>
|
|
@@ -33,7 +33,7 @@ usePageSeo(() => ({
|
|
|
33
33
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
34
34
|
<div class="mx-auto flex max-w-page flex-col gap-12 px-gutter md:gap-16">
|
|
35
35
|
<component :is="contact" v-if="contact" />
|
|
36
|
-
<PeopleList v-if="people.items.length" :items="people.items" />
|
|
36
|
+
<PeopleList v-if="people.items.length" :items="people.items" eager />
|
|
37
37
|
<BlockEmpty v-else :title="t('empty')" path="content/people/" />
|
|
38
38
|
</div>
|
|
39
39
|
</div>
|
|
@@ -13,6 +13,8 @@ export default defineNuxtConfig({
|
|
|
13
13
|
'shadcn-nuxt',
|
|
14
14
|
],
|
|
15
15
|
ginko,
|
|
16
|
+
// The site's icon: the initial on the brand colour. Replace public/favicon.svg with the real mark.
|
|
17
|
+
app: { head: { link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }] } },
|
|
16
18
|
css: ['~/assets/css/main.css'],
|
|
17
19
|
// Light and dark follow the visitor's system until they pick one with the toggle; `.dark` on <html>.
|
|
18
20
|
colorMode: { classSuffix: '', preference: 'system', fallback: 'light', storageKey: 'color-mode' },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#4f46e5"/><text x="16" y="22.5" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="19" font-weight="700" fill="#fff">F</text></svg>
|
|
@@ -16,8 +16,9 @@ export interface Card {
|
|
|
16
16
|
/** Two to four cards per row; the whole card is one link (the title's link stretches over it), marked by an arrow.
|
|
17
17
|
* Photos are 3:2. `headingLevel`: h3 under a section's h2 (default), h2 when the cards follow the page's h1.
|
|
18
18
|
* `compact`: on phones (below `sm`) each card is one row, a square photo of 96 px at the start next to the meta line
|
|
19
|
-
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
-
|
|
19
|
+
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
+
* `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
21
|
+
const props = withDefaults(defineProps<{ items: Card[], columns?: 2 | 3 | 4, headingLevel?: 'h2' | 'h3', compact?: boolean, eager?: boolean }>(), { columns: 3, headingLevel: 'h3' })
|
|
21
22
|
// Never an empty column at lg: fewer items than `columns` take that many columns (two at least; one item stays as is).
|
|
22
23
|
const lgColumns = computed(() => (props.items.length > 1 ? Math.min(props.columns, Math.max(2, props.items.length)) : props.columns))
|
|
23
24
|
const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw, ${props.compact ? '96px' : '100vw'}`)
|
|
@@ -29,7 +30,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
29
30
|
:class="{ 'lg:grid-cols-3': lgColumns === 3, 'lg:grid-cols-4': lgColumns === 4, 'max-sm:gap-3': compact }"
|
|
30
31
|
>
|
|
31
32
|
<li
|
|
32
|
-
v-for="item in items"
|
|
33
|
+
v-for="(item, i) in items"
|
|
33
34
|
:key="item.key"
|
|
34
35
|
class="group relative flex overflow-hidden rounded-block border bg-card transition-[color,background-color,border-color] duration-150 hover:bg-muted has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-3 has-[a:focus-visible]:outline-ring"
|
|
35
36
|
:class="compact ? 'max-sm:items-center sm:flex-col' : 'flex-col'"
|
|
@@ -38,6 +39,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
38
39
|
v-if="item.image"
|
|
39
40
|
:image="item.image"
|
|
40
41
|
:sizes="sizes"
|
|
42
|
+
:eager="eager && i === 0"
|
|
41
43
|
class="aspect-3/2 w-full bg-muted object-cover"
|
|
42
44
|
:class="{ 'max-sm:m-3 max-sm:me-0 max-sm:aspect-square max-sm:size-24 max-sm:shrink-0 max-sm:rounded-md': compact }"
|
|
43
45
|
/>
|
|
@@ -4,18 +4,19 @@ import type { BlogPost } from '~/composables/useBlog'
|
|
|
4
4
|
|
|
5
5
|
/** Posts as rows divided by lines: date and authors, title (the link), summary, and the cover (3:2) at the end of the
|
|
6
6
|
* row from `sm` up, above the text on phones. A post without a cover is a row without an image, not a gap. `images` false
|
|
7
|
-
* leaves the covers out (a teaser on a home page). */
|
|
8
|
-
withDefaults(defineProps<{ items: BlogPost[], headingLevel?: 'h2' | 'h3', images?: boolean }>(), { headingLevel: 'h2', images: true })
|
|
7
|
+
* leaves the covers out (a teaser on a home page). `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
8
|
+
withDefaults(defineProps<{ items: BlogPost[], headingLevel?: 'h2' | 'h3', images?: boolean, eager?: boolean }>(), { headingLevel: 'h2', images: true })
|
|
9
9
|
const { format, t } = useI18n('blog')
|
|
10
10
|
</script>
|
|
11
11
|
|
|
12
12
|
<template>
|
|
13
13
|
<ul class="flex flex-col border-b">
|
|
14
|
-
<li v-for="post in items" :key="post.id" class="group relative flex flex-col gap-5 border-t py-8 sm:flex-row-reverse sm:items-start sm:gap-8">
|
|
14
|
+
<li v-for="(post, i) in items" :key="post.id" class="group relative flex flex-col gap-5 border-t py-8 sm:flex-row-reverse sm:items-start sm:gap-8">
|
|
15
15
|
<GinkoImage
|
|
16
16
|
v-if="images && post.image"
|
|
17
17
|
:image="post.image"
|
|
18
18
|
sizes="(min-width: 640px) 224px, 100vw"
|
|
19
|
+
:eager="eager && i === 0"
|
|
19
20
|
class="aspect-3/2 w-full shrink-0 rounded-block bg-muted object-cover sm:w-56"
|
|
20
21
|
/>
|
|
21
22
|
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
<script setup lang="ts">
|
|
3
3
|
/** People as a grid: photo (4:5) or initials, name (a link to the person's page when people have pages), role, short
|
|
4
4
|
* bio and contact. Never a stock face for a named person: without a photo the initials stand in, on phones small and
|
|
5
|
-
* beside the name, so the bios stay close. */
|
|
6
|
-
defineProps<{ items: GinkoItem<'people'>[] }>()
|
|
5
|
+
* beside the name, so the bios stay close. `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
6
|
+
defineProps<{ items: GinkoItem<'people'>[], eager?: boolean }>()
|
|
7
7
|
const { t } = useI18n('people')
|
|
8
8
|
const locale = useGinkoLocale()
|
|
9
9
|
</script>
|
|
10
10
|
|
|
11
11
|
<template>
|
|
12
12
|
<ul class="grid gap-x-10 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
|
|
13
|
-
<li v-for="person in items" :id="person.id.split('/').pop()" :key="person.id" class="flex flex-col gap-4 break-inside-avoid">
|
|
13
|
+
<li v-for="(person, i) in items" :id="person.id.split('/').pop()" :key="person.id" class="flex flex-col gap-4 break-inside-avoid">
|
|
14
14
|
<GinkoImage
|
|
15
15
|
v-if="person.data.photo"
|
|
16
|
+
:eager="eager && i === 0"
|
|
16
17
|
:image="person.data.photo"
|
|
17
18
|
sizes="(min-width: 1024px) 384px, (min-width: 640px) 50vw, 100vw"
|
|
18
19
|
class="aspect-4/5 w-full rounded-block bg-muted object-cover"
|
|
@@ -13,7 +13,7 @@ usePageSeo(() => ({ title: t('title'), description: t('lead') }))
|
|
|
13
13
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
14
14
|
<div class="mx-auto max-w-page px-gutter">
|
|
15
15
|
<h2 v-if="cards.length" class="sr-only">{{ t('upcoming') }}</h2>
|
|
16
|
-
<BlockCards v-if="cards.length" :items="cards" :columns="3" />
|
|
16
|
+
<BlockCards v-if="cards.length" :items="cards" :columns="3" eager />
|
|
17
17
|
<p v-else class="max-w-prose text-lead text-muted-foreground">{{ t('none') }}</p>
|
|
18
18
|
</div>
|
|
19
19
|
</div>
|
|
@@ -40,10 +40,10 @@ usePageSeo(() => ({
|
|
|
40
40
|
<template v-if="years.length > 1">
|
|
41
41
|
<section v-for="group in years" :key="group.year" :aria-labelledby="`year-${group.year}`" class="pt-6 first:pt-0">
|
|
42
42
|
<h2 :id="`year-${group.year}`" class="pb-2 text-h3 tabular-nums">{{ group.year }}</h2>
|
|
43
|
-
<BlogPostList :items="group.posts" heading-level="h3" />
|
|
43
|
+
<BlogPostList :items="group.posts" heading-level="h3" :eager="group === years[0]" />
|
|
44
44
|
</section>
|
|
45
45
|
</template>
|
|
46
|
-
<BlogPostList v-else-if="posts.length" :items="posts" />
|
|
46
|
+
<BlogPostList v-else-if="posts.length" :items="posts" eager />
|
|
47
47
|
<BlockEmpty v-else :title="t('empty')" path="content/posts/" />
|
|
48
48
|
<a :href="feed" class="mt-6 inline-flex min-h-11 items-center gap-2 text-small text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
|
|
49
49
|
<Rss class="size-4" aria-hidden="true" />{{ t('feed') }}
|
|
@@ -31,7 +31,7 @@ usePageSeo(() => ({ title: title.value, description: t('count', { count: tag.val
|
|
|
31
31
|
<BlockPageHeader :title="title" :meta="t('count', { count: tag.count })" />
|
|
32
32
|
<div class="mx-auto max-w-page px-gutter">
|
|
33
33
|
<div class="max-w-4xl">
|
|
34
|
-
<BlogPostList :items="tagged" />
|
|
34
|
+
<BlogPostList :items="tagged" eager />
|
|
35
35
|
<NuxtLink :to="path('news')" class="mt-6 inline-flex min-h-11 items-center gap-2 font-medium text-primary underline-offset-4 hover:underline">
|
|
36
36
|
{{ t('all') }}<ArrowRight class="size-4 rtl:-scale-x-100" aria-hidden="true" />
|
|
37
37
|
</NuxtLink>
|
|
@@ -11,7 +11,7 @@ usePageSeo(() => ({ title: t('title'), description: t('lead') }))
|
|
|
11
11
|
<div>
|
|
12
12
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
13
13
|
<div class="mx-auto max-w-page px-gutter">
|
|
14
|
-
<BlockCards v-if="cards.length" :items="cards" heading-level="h2" />
|
|
14
|
+
<BlockCards v-if="cards.length" :items="cards" heading-level="h2" eager />
|
|
15
15
|
<BlockEmpty v-else :title="t('empty')" path="content/services/" />
|
|
16
16
|
</div>
|
|
17
17
|
</div>
|
|
@@ -33,7 +33,7 @@ usePageSeo(() => ({
|
|
|
33
33
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
34
34
|
<div class="mx-auto flex max-w-page flex-col gap-12 px-gutter md:gap-16">
|
|
35
35
|
<component :is="contact" v-if="contact" />
|
|
36
|
-
<PeopleList v-if="people.items.length" :items="people.items" />
|
|
36
|
+
<PeopleList v-if="people.items.length" :items="people.items" eager />
|
|
37
37
|
<BlockEmpty v-else :title="t('empty')" path="content/people/" />
|
|
38
38
|
</div>
|
|
39
39
|
</div>
|
|
@@ -13,6 +13,8 @@ export default defineNuxtConfig({
|
|
|
13
13
|
'shadcn-nuxt',
|
|
14
14
|
],
|
|
15
15
|
ginko,
|
|
16
|
+
// The site's icon: the initial on the brand colour. Replace public/favicon.svg with the real mark.
|
|
17
|
+
app: { head: { link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }] } },
|
|
16
18
|
css: ['~/assets/css/main.css'],
|
|
17
19
|
// Light and dark follow the visitor's system until they pick one with the toggle; `.dark` on <html>.
|
|
18
20
|
colorMode: { classSuffix: '', preference: 'system', fallback: 'light', storageKey: 'color-mode' },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#0369a1"/><text x="16" y="22.5" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="19" font-weight="700" fill="#fff">H</text></svg>
|
|
@@ -16,8 +16,9 @@ export interface Card {
|
|
|
16
16
|
/** Two to four cards per row; the whole card is one link (the title's link stretches over it), marked by an arrow.
|
|
17
17
|
* Photos are 3:2. `headingLevel`: h3 under a section's h2 (default), h2 when the cards follow the page's h1.
|
|
18
18
|
* `compact`: on phones (below `sm`) each card is one row, a square photo of 96 px at the start next to the meta line
|
|
19
|
-
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
-
|
|
19
|
+
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
+
* `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
21
|
+
const props = withDefaults(defineProps<{ items: Card[], columns?: 2 | 3 | 4, headingLevel?: 'h2' | 'h3', compact?: boolean, eager?: boolean }>(), { columns: 3, headingLevel: 'h3' })
|
|
21
22
|
// Never an empty column at lg: fewer items than `columns` take that many columns (two at least; one item stays as is).
|
|
22
23
|
const lgColumns = computed(() => (props.items.length > 1 ? Math.min(props.columns, Math.max(2, props.items.length)) : props.columns))
|
|
23
24
|
const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw, ${props.compact ? '96px' : '100vw'}`)
|
|
@@ -29,7 +30,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
29
30
|
:class="{ 'lg:grid-cols-3': lgColumns === 3, 'lg:grid-cols-4': lgColumns === 4, 'max-sm:gap-3': compact }"
|
|
30
31
|
>
|
|
31
32
|
<li
|
|
32
|
-
v-for="item in items"
|
|
33
|
+
v-for="(item, i) in items"
|
|
33
34
|
:key="item.key"
|
|
34
35
|
class="group relative flex overflow-hidden rounded-block border bg-card transition-[color,background-color,border-color] duration-150 hover:bg-muted has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-3 has-[a:focus-visible]:outline-ring"
|
|
35
36
|
:class="compact ? 'max-sm:items-center sm:flex-col' : 'flex-col'"
|
|
@@ -38,6 +39,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
38
39
|
v-if="item.image"
|
|
39
40
|
:image="item.image"
|
|
40
41
|
:sizes="sizes"
|
|
42
|
+
:eager="eager && i === 0"
|
|
41
43
|
class="aspect-3/2 w-full bg-muted object-cover"
|
|
42
44
|
:class="{ 'max-sm:m-3 max-sm:me-0 max-sm:aspect-square max-sm:size-24 max-sm:shrink-0 max-sm:rounded-md': compact }"
|
|
43
45
|
/>
|
|
@@ -9,10 +9,10 @@ import type { ImageValue } from '@lupinum/ginko'
|
|
|
9
9
|
* takes the rest of a short last row, so its photos keep about the row height instead of growing past the rows above.
|
|
10
10
|
* Each photo is a button that opens the lightbox at that photo.
|
|
11
11
|
* `stack`: one photo below the other at full width, each in its own ratio, the first loaded at once (the main photos of
|
|
12
|
-
* a page, such as the photos of one project). A photo's description (`alt`) is not shown below it; it stays on the
|
|
12
|
+
* a page, such as the photos of one project). `eager`: the gallery opens the page, so its first photo loads at once. A photo's description (`alt`) is not shown below it; it stays on the
|
|
13
13
|
* image and in the name of the photo's button.
|
|
14
14
|
*/
|
|
15
|
-
const props = defineProps<{ images: ImageValue[], stack?: boolean }>()
|
|
15
|
+
const props = defineProps<{ images: ImageValue[], stack?: boolean, eager?: boolean }>()
|
|
16
16
|
const { t } = useI18n('gallery')
|
|
17
17
|
const list = useTemplateRef<HTMLUListElement>('list')
|
|
18
18
|
const open = ref(false)
|
|
@@ -68,7 +68,7 @@ function focusPhoto(i: number) {
|
|
|
68
68
|
<GinkoImage
|
|
69
69
|
:image="image"
|
|
70
70
|
:sizes="sizes"
|
|
71
|
-
:eager="stack && i === 0"
|
|
71
|
+
:eager="(stack || eager) && i === 0"
|
|
72
72
|
class="w-full"
|
|
73
73
|
:class="!stack && ['aspect-3/2 object-cover', rows && 'sm:aspect-(--ratio)']"
|
|
74
74
|
/>
|
|
@@ -13,7 +13,7 @@ usePageSeo(() => ({ title: t('title'), description: t('lead') }))
|
|
|
13
13
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
14
14
|
<div class="mx-auto max-w-page px-gutter">
|
|
15
15
|
<h2 v-if="cards.length" class="sr-only">{{ t('upcoming') }}</h2>
|
|
16
|
-
<BlockCards v-if="cards.length" :items="cards" :columns="3" />
|
|
16
|
+
<BlockCards v-if="cards.length" :items="cards" :columns="3" eager />
|
|
17
17
|
<p v-else class="max-w-prose text-lead text-muted-foreground">{{ t('none') }}</p>
|
|
18
18
|
</div>
|
|
19
19
|
</div>
|
|
@@ -13,6 +13,8 @@ export default defineNuxtConfig({
|
|
|
13
13
|
'shadcn-nuxt',
|
|
14
14
|
],
|
|
15
15
|
ginko,
|
|
16
|
+
// The site's icon: the initial on the brand colour. Replace public/favicon.svg with the real mark.
|
|
17
|
+
app: { head: { link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }] } },
|
|
16
18
|
css: ['~/assets/css/main.css'],
|
|
17
19
|
// Light and dark follow the visitor's system until they pick one with the toggle; `.dark` on <html>.
|
|
18
20
|
colorMode: { classSuffix: '', preference: 'system', fallback: 'light', storageKey: 'color-mode' },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#be123c"/><text x="16" y="22.5" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="19" font-weight="700" fill="#fff">O</text></svg>
|
|
@@ -16,8 +16,9 @@ export interface Card {
|
|
|
16
16
|
/** Two to four cards per row; the whole card is one link (the title's link stretches over it), marked by an arrow.
|
|
17
17
|
* Photos are 3:2. `headingLevel`: h3 under a section's h2 (default), h2 when the cards follow the page's h1.
|
|
18
18
|
* `compact`: on phones (below `sm`) each card is one row, a square photo of 96 px at the start next to the meta line
|
|
19
|
-
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
-
|
|
19
|
+
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
+
* `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
21
|
+
const props = withDefaults(defineProps<{ items: Card[], columns?: 2 | 3 | 4, headingLevel?: 'h2' | 'h3', compact?: boolean, eager?: boolean }>(), { columns: 3, headingLevel: 'h3' })
|
|
21
22
|
// Never an empty column at lg: fewer items than `columns` take that many columns (two at least; one item stays as is).
|
|
22
23
|
const lgColumns = computed(() => (props.items.length > 1 ? Math.min(props.columns, Math.max(2, props.items.length)) : props.columns))
|
|
23
24
|
const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw, ${props.compact ? '96px' : '100vw'}`)
|
|
@@ -29,7 +30,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
29
30
|
:class="{ 'lg:grid-cols-3': lgColumns === 3, 'lg:grid-cols-4': lgColumns === 4, 'max-sm:gap-3': compact }"
|
|
30
31
|
>
|
|
31
32
|
<li
|
|
32
|
-
v-for="item in items"
|
|
33
|
+
v-for="(item, i) in items"
|
|
33
34
|
:key="item.key"
|
|
34
35
|
class="group relative flex overflow-hidden rounded-block border bg-card transition-[color,background-color,border-color] duration-150 hover:bg-muted has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-3 has-[a:focus-visible]:outline-ring"
|
|
35
36
|
:class="compact ? 'max-sm:items-center sm:flex-col' : 'flex-col'"
|
|
@@ -38,6 +39,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
38
39
|
v-if="item.image"
|
|
39
40
|
:image="item.image"
|
|
40
41
|
:sizes="sizes"
|
|
42
|
+
:eager="eager && i === 0"
|
|
41
43
|
class="aspect-3/2 w-full bg-muted object-cover"
|
|
42
44
|
:class="{ 'max-sm:m-3 max-sm:me-0 max-sm:aspect-square max-sm:size-24 max-sm:shrink-0 max-sm:rounded-md': compact }"
|
|
43
45
|
/>
|
|
@@ -9,10 +9,10 @@ import type { ImageValue } from '@lupinum/ginko'
|
|
|
9
9
|
* takes the rest of a short last row, so its photos keep about the row height instead of growing past the rows above.
|
|
10
10
|
* Each photo is a button that opens the lightbox at that photo.
|
|
11
11
|
* `stack`: one photo below the other at full width, each in its own ratio, the first loaded at once (the main photos of
|
|
12
|
-
* a page, such as the photos of one project). A photo's description (`alt`) is not shown below it; it stays on the
|
|
12
|
+
* a page, such as the photos of one project). `eager`: the gallery opens the page, so its first photo loads at once. A photo's description (`alt`) is not shown below it; it stays on the
|
|
13
13
|
* image and in the name of the photo's button.
|
|
14
14
|
*/
|
|
15
|
-
const props = defineProps<{ images: ImageValue[], stack?: boolean }>()
|
|
15
|
+
const props = defineProps<{ images: ImageValue[], stack?: boolean, eager?: boolean }>()
|
|
16
16
|
const { t } = useI18n('gallery')
|
|
17
17
|
const list = useTemplateRef<HTMLUListElement>('list')
|
|
18
18
|
const open = ref(false)
|
|
@@ -68,7 +68,7 @@ function focusPhoto(i: number) {
|
|
|
68
68
|
<GinkoImage
|
|
69
69
|
:image="image"
|
|
70
70
|
:sizes="sizes"
|
|
71
|
-
:eager="stack && i === 0"
|
|
71
|
+
:eager="(stack || eager) && i === 0"
|
|
72
72
|
class="w-full"
|
|
73
73
|
:class="!stack && ['aspect-3/2 object-cover', rows && 'sm:aspect-(--ratio)']"
|
|
74
74
|
/>
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
<script setup lang="ts">
|
|
3
3
|
/** People as a grid: photo (4:5) or initials, name (a link to the person's page when people have pages), role, short
|
|
4
4
|
* bio and contact. Never a stock face for a named person: without a photo the initials stand in, on phones small and
|
|
5
|
-
* beside the name, so the bios stay close. */
|
|
6
|
-
defineProps<{ items: GinkoItem<'people'>[] }>()
|
|
5
|
+
* beside the name, so the bios stay close. `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
6
|
+
defineProps<{ items: GinkoItem<'people'>[], eager?: boolean }>()
|
|
7
7
|
const { t } = useI18n('people')
|
|
8
8
|
const locale = useGinkoLocale()
|
|
9
9
|
</script>
|
|
10
10
|
|
|
11
11
|
<template>
|
|
12
12
|
<ul class="grid gap-x-10 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
|
|
13
|
-
<li v-for="person in items" :id="person.id.split('/').pop()" :key="person.id" class="flex flex-col gap-4 break-inside-avoid">
|
|
13
|
+
<li v-for="(person, i) in items" :id="person.id.split('/').pop()" :key="person.id" class="flex flex-col gap-4 break-inside-avoid">
|
|
14
14
|
<GinkoImage
|
|
15
15
|
v-if="person.data.photo"
|
|
16
|
+
:eager="eager && i === 0"
|
|
16
17
|
:image="person.data.photo"
|
|
17
18
|
sizes="(min-width: 1024px) 384px, (min-width: 640px) 50vw, 100vw"
|
|
18
19
|
class="aspect-4/5 w-full rounded-block bg-muted object-cover"
|
|
@@ -11,7 +11,7 @@ usePageSeo(() => ({ title: t('title'), description: t('lead') }))
|
|
|
11
11
|
<div>
|
|
12
12
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
13
13
|
<div class="mx-auto max-w-page px-gutter">
|
|
14
|
-
<BlockCards v-if="cards.length" :items="cards" heading-level="h2" />
|
|
14
|
+
<BlockCards v-if="cards.length" :items="cards" heading-level="h2" eager />
|
|
15
15
|
<BlockEmpty v-else :title="t('empty')" path="content/services/" />
|
|
16
16
|
</div>
|
|
17
17
|
</div>
|
|
@@ -33,7 +33,7 @@ usePageSeo(() => ({
|
|
|
33
33
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
34
34
|
<div class="mx-auto flex max-w-page flex-col gap-12 px-gutter md:gap-16">
|
|
35
35
|
<component :is="contact" v-if="contact" />
|
|
36
|
-
<PeopleList v-if="people.items.length" :items="people.items" />
|
|
36
|
+
<PeopleList v-if="people.items.length" :items="people.items" eager />
|
|
37
37
|
<BlockEmpty v-else :title="t('empty')" path="content/people/" />
|
|
38
38
|
</div>
|
|
39
39
|
</div>
|
|
@@ -13,6 +13,8 @@ export default defineNuxtConfig({
|
|
|
13
13
|
'shadcn-nuxt',
|
|
14
14
|
],
|
|
15
15
|
ginko,
|
|
16
|
+
// The site's icon: the initial on the brand colour. Replace public/favicon.svg with the real mark.
|
|
17
|
+
app: { head: { link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }] } },
|
|
16
18
|
css: ['~/assets/css/main.css'],
|
|
17
19
|
// Light and dark follow the visitor's system until they pick one with the toggle; `.dark` on <html>.
|
|
18
20
|
colorMode: { classSuffix: '', preference: 'system', fallback: 'light', storageKey: 'color-mode' },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#0f766e"/><text x="16" y="22.5" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="19" font-weight="700" fill="#fff">H</text></svg>
|
|
@@ -16,8 +16,9 @@ export interface Card {
|
|
|
16
16
|
/** Two to four cards per row; the whole card is one link (the title's link stretches over it), marked by an arrow.
|
|
17
17
|
* Photos are 3:2. `headingLevel`: h3 under a section's h2 (default), h2 when the cards follow the page's h1.
|
|
18
18
|
* `compact`: on phones (below `sm`) each card is one row, a square photo of 96 px at the start next to the meta line
|
|
19
|
-
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
-
|
|
19
|
+
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
+
* `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
21
|
+
const props = withDefaults(defineProps<{ items: Card[], columns?: 2 | 3 | 4, headingLevel?: 'h2' | 'h3', compact?: boolean, eager?: boolean }>(), { columns: 3, headingLevel: 'h3' })
|
|
21
22
|
// Never an empty column at lg: fewer items than `columns` take that many columns (two at least; one item stays as is).
|
|
22
23
|
const lgColumns = computed(() => (props.items.length > 1 ? Math.min(props.columns, Math.max(2, props.items.length)) : props.columns))
|
|
23
24
|
const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw, ${props.compact ? '96px' : '100vw'}`)
|
|
@@ -29,7 +30,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
29
30
|
:class="{ 'lg:grid-cols-3': lgColumns === 3, 'lg:grid-cols-4': lgColumns === 4, 'max-sm:gap-3': compact }"
|
|
30
31
|
>
|
|
31
32
|
<li
|
|
32
|
-
v-for="item in items"
|
|
33
|
+
v-for="(item, i) in items"
|
|
33
34
|
:key="item.key"
|
|
34
35
|
class="group relative flex overflow-hidden rounded-block border bg-card transition-[color,background-color,border-color] duration-150 hover:bg-muted has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-3 has-[a:focus-visible]:outline-ring"
|
|
35
36
|
:class="compact ? 'max-sm:items-center sm:flex-col' : 'flex-col'"
|
|
@@ -38,6 +39,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
38
39
|
v-if="item.image"
|
|
39
40
|
:image="item.image"
|
|
40
41
|
:sizes="sizes"
|
|
42
|
+
:eager="eager && i === 0"
|
|
41
43
|
class="aspect-3/2 w-full bg-muted object-cover"
|
|
42
44
|
:class="{ 'max-sm:m-3 max-sm:me-0 max-sm:aspect-square max-sm:size-24 max-sm:shrink-0 max-sm:rounded-md': compact }"
|
|
43
45
|
/>
|
|
@@ -4,18 +4,19 @@ import type { BlogPost } from '~/composables/useBlog'
|
|
|
4
4
|
|
|
5
5
|
/** Posts as rows divided by lines: date and authors, title (the link), summary, and the cover (3:2) at the end of the
|
|
6
6
|
* row from `sm` up, above the text on phones. A post without a cover is a row without an image, not a gap. `images` false
|
|
7
|
-
* leaves the covers out (a teaser on a home page). */
|
|
8
|
-
withDefaults(defineProps<{ items: BlogPost[], headingLevel?: 'h2' | 'h3', images?: boolean }>(), { headingLevel: 'h2', images: true })
|
|
7
|
+
* leaves the covers out (a teaser on a home page). `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
8
|
+
withDefaults(defineProps<{ items: BlogPost[], headingLevel?: 'h2' | 'h3', images?: boolean, eager?: boolean }>(), { headingLevel: 'h2', images: true })
|
|
9
9
|
const { format, t } = useI18n('blog')
|
|
10
10
|
</script>
|
|
11
11
|
|
|
12
12
|
<template>
|
|
13
13
|
<ul class="flex flex-col border-b">
|
|
14
|
-
<li v-for="post in items" :key="post.id" class="group relative flex flex-col gap-5 border-t py-8 sm:flex-row-reverse sm:items-start sm:gap-8">
|
|
14
|
+
<li v-for="(post, i) in items" :key="post.id" class="group relative flex flex-col gap-5 border-t py-8 sm:flex-row-reverse sm:items-start sm:gap-8">
|
|
15
15
|
<GinkoImage
|
|
16
16
|
v-if="images && post.image"
|
|
17
17
|
:image="post.image"
|
|
18
18
|
sizes="(min-width: 640px) 224px, 100vw"
|
|
19
|
+
:eager="eager && i === 0"
|
|
19
20
|
class="aspect-3/2 w-full shrink-0 rounded-block bg-muted object-cover sm:w-56"
|
|
20
21
|
/>
|
|
21
22
|
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
<script setup lang="ts">
|
|
3
3
|
/** People as a grid: photo (4:5) or initials, name (a link to the person's page when people have pages), role, short
|
|
4
4
|
* bio and contact. Never a stock face for a named person: without a photo the initials stand in, on phones small and
|
|
5
|
-
* beside the name, so the bios stay close. */
|
|
6
|
-
defineProps<{ items: GinkoItem<'people'>[] }>()
|
|
5
|
+
* beside the name, so the bios stay close. `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
6
|
+
defineProps<{ items: GinkoItem<'people'>[], eager?: boolean }>()
|
|
7
7
|
const { t } = useI18n('people')
|
|
8
8
|
const locale = useGinkoLocale()
|
|
9
9
|
</script>
|
|
10
10
|
|
|
11
11
|
<template>
|
|
12
12
|
<ul class="grid gap-x-10 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
|
|
13
|
-
<li v-for="person in items" :id="person.id.split('/').pop()" :key="person.id" class="flex flex-col gap-4 break-inside-avoid">
|
|
13
|
+
<li v-for="(person, i) in items" :id="person.id.split('/').pop()" :key="person.id" class="flex flex-col gap-4 break-inside-avoid">
|
|
14
14
|
<GinkoImage
|
|
15
15
|
v-if="person.data.photo"
|
|
16
|
+
:eager="eager && i === 0"
|
|
16
17
|
:image="person.data.photo"
|
|
17
18
|
sizes="(min-width: 1024px) 384px, (min-width: 640px) 50vw, 100vw"
|
|
18
19
|
class="aspect-4/5 w-full rounded-block bg-muted object-cover"
|
|
@@ -13,7 +13,7 @@ usePageSeo(() => ({ title: t('title'), description: t('lead') }))
|
|
|
13
13
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
14
14
|
<div class="mx-auto max-w-page px-gutter">
|
|
15
15
|
<h2 v-if="cards.length" class="sr-only">{{ t('upcoming') }}</h2>
|
|
16
|
-
<BlockCards v-if="cards.length" :items="cards" :columns="3" />
|
|
16
|
+
<BlockCards v-if="cards.length" :items="cards" :columns="3" eager />
|
|
17
17
|
<p v-else class="max-w-prose text-lead text-muted-foreground">{{ t('none') }}</p>
|
|
18
18
|
</div>
|
|
19
19
|
</div>
|
|
@@ -40,10 +40,10 @@ usePageSeo(() => ({
|
|
|
40
40
|
<template v-if="years.length > 1">
|
|
41
41
|
<section v-for="group in years" :key="group.year" :aria-labelledby="`year-${group.year}`" class="pt-6 first:pt-0">
|
|
42
42
|
<h2 :id="`year-${group.year}`" class="pb-2 text-h3 tabular-nums">{{ group.year }}</h2>
|
|
43
|
-
<BlogPostList :items="group.posts" heading-level="h3" />
|
|
43
|
+
<BlogPostList :items="group.posts" heading-level="h3" :eager="group === years[0]" />
|
|
44
44
|
</section>
|
|
45
45
|
</template>
|
|
46
|
-
<BlogPostList v-else-if="posts.length" :items="posts" />
|
|
46
|
+
<BlogPostList v-else-if="posts.length" :items="posts" eager />
|
|
47
47
|
<BlockEmpty v-else :title="t('empty')" path="content/posts/" />
|
|
48
48
|
<a :href="feed" class="mt-6 inline-flex min-h-11 items-center gap-2 text-small text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
|
|
49
49
|
<Rss class="size-4" aria-hidden="true" />{{ t('feed') }}
|
|
@@ -31,7 +31,7 @@ usePageSeo(() => ({ title: title.value, description: t('count', { count: tag.val
|
|
|
31
31
|
<BlockPageHeader :title="title" :meta="t('count', { count: tag.count })" />
|
|
32
32
|
<div class="mx-auto max-w-page px-gutter">
|
|
33
33
|
<div class="max-w-4xl">
|
|
34
|
-
<BlogPostList :items="tagged" />
|
|
34
|
+
<BlogPostList :items="tagged" eager />
|
|
35
35
|
<NuxtLink :to="path('news')" class="mt-6 inline-flex min-h-11 items-center gap-2 font-medium text-primary underline-offset-4 hover:underline">
|
|
36
36
|
{{ t('all') }}<ArrowRight class="size-4 rtl:-scale-x-100" aria-hidden="true" />
|
|
37
37
|
</NuxtLink>
|
|
@@ -17,7 +17,7 @@ usePageSeo(() => ({ title: t('title'), description: t('lead') }))
|
|
|
17
17
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
18
18
|
<div class="mx-auto max-w-page px-gutter">
|
|
19
19
|
<component :is="List" v-if="List && projects.items.length" :items="projects.items" />
|
|
20
|
-
<BlockCards v-else-if="cards.length" :items="cards" heading-level="h2" />
|
|
20
|
+
<BlockCards v-else-if="cards.length" :items="cards" heading-level="h2" eager />
|
|
21
21
|
<BlockEmpty v-else :title="t('empty')" path="content/projects/" />
|
|
22
22
|
</div>
|
|
23
23
|
</div>
|
|
@@ -33,7 +33,7 @@ usePageSeo(() => ({
|
|
|
33
33
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
34
34
|
<div class="mx-auto flex max-w-page flex-col gap-12 px-gutter md:gap-16">
|
|
35
35
|
<component :is="contact" v-if="contact" />
|
|
36
|
-
<PeopleList v-if="people.items.length" :items="people.items" />
|
|
36
|
+
<PeopleList v-if="people.items.length" :items="people.items" eager />
|
|
37
37
|
<BlockEmpty v-else :title="t('empty')" path="content/people/" />
|
|
38
38
|
</div>
|
|
39
39
|
</div>
|
|
@@ -13,6 +13,8 @@ export default defineNuxtConfig({
|
|
|
13
13
|
'shadcn-nuxt',
|
|
14
14
|
],
|
|
15
15
|
ginko,
|
|
16
|
+
// The site's icon: the initial on the brand colour. Replace public/favicon.svg with the real mark.
|
|
17
|
+
app: { head: { link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }] } },
|
|
16
18
|
css: ['~/assets/css/main.css'],
|
|
17
19
|
// Light and dark follow the visitor's system until they pick one with the toggle; `.dark` on <html>.
|
|
18
20
|
colorMode: { classSuffix: '', preference: 'system', fallback: 'light', storageKey: 'color-mode' },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#15803d"/><text x="16" y="22.5" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="19" font-weight="700" fill="#fff">R</text></svg>
|
|
@@ -16,8 +16,9 @@ export interface Card {
|
|
|
16
16
|
/** Two to four cards per row; the whole card is one link (the title's link stretches over it), marked by an arrow.
|
|
17
17
|
* Photos are 3:2. `headingLevel`: h3 under a section's h2 (default), h2 when the cards follow the page's h1.
|
|
18
18
|
* `compact`: on phones (below `sm`) each card is one row, a square photo of 96 px at the start next to the meta line
|
|
19
|
-
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
-
|
|
19
|
+
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
+
* `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
21
|
+
const props = withDefaults(defineProps<{ items: Card[], columns?: 2 | 3 | 4, headingLevel?: 'h2' | 'h3', compact?: boolean, eager?: boolean }>(), { columns: 3, headingLevel: 'h3' })
|
|
21
22
|
// Never an empty column at lg: fewer items than `columns` take that many columns (two at least; one item stays as is).
|
|
22
23
|
const lgColumns = computed(() => (props.items.length > 1 ? Math.min(props.columns, Math.max(2, props.items.length)) : props.columns))
|
|
23
24
|
const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw, ${props.compact ? '96px' : '100vw'}`)
|
|
@@ -29,7 +30,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
29
30
|
:class="{ 'lg:grid-cols-3': lgColumns === 3, 'lg:grid-cols-4': lgColumns === 4, 'max-sm:gap-3': compact }"
|
|
30
31
|
>
|
|
31
32
|
<li
|
|
32
|
-
v-for="item in items"
|
|
33
|
+
v-for="(item, i) in items"
|
|
33
34
|
:key="item.key"
|
|
34
35
|
class="group relative flex overflow-hidden rounded-block border bg-card transition-[color,background-color,border-color] duration-150 hover:bg-muted has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-3 has-[a:focus-visible]:outline-ring"
|
|
35
36
|
:class="compact ? 'max-sm:items-center sm:flex-col' : 'flex-col'"
|
|
@@ -38,6 +39,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
38
39
|
v-if="item.image"
|
|
39
40
|
:image="item.image"
|
|
40
41
|
:sizes="sizes"
|
|
42
|
+
:eager="eager && i === 0"
|
|
41
43
|
class="aspect-3/2 w-full bg-muted object-cover"
|
|
42
44
|
:class="{ 'max-sm:m-3 max-sm:me-0 max-sm:aspect-square max-sm:size-24 max-sm:shrink-0 max-sm:rounded-md': compact }"
|
|
43
45
|
/>
|
|
@@ -9,10 +9,10 @@ import type { ImageValue } from '@lupinum/ginko'
|
|
|
9
9
|
* takes the rest of a short last row, so its photos keep about the row height instead of growing past the rows above.
|
|
10
10
|
* Each photo is a button that opens the lightbox at that photo.
|
|
11
11
|
* `stack`: one photo below the other at full width, each in its own ratio, the first loaded at once (the main photos of
|
|
12
|
-
* a page, such as the photos of one project). A photo's description (`alt`) is not shown below it; it stays on the
|
|
12
|
+
* a page, such as the photos of one project). `eager`: the gallery opens the page, so its first photo loads at once. A photo's description (`alt`) is not shown below it; it stays on the
|
|
13
13
|
* image and in the name of the photo's button.
|
|
14
14
|
*/
|
|
15
|
-
const props = defineProps<{ images: ImageValue[], stack?: boolean }>()
|
|
15
|
+
const props = defineProps<{ images: ImageValue[], stack?: boolean, eager?: boolean }>()
|
|
16
16
|
const { t } = useI18n('gallery')
|
|
17
17
|
const list = useTemplateRef<HTMLUListElement>('list')
|
|
18
18
|
const open = ref(false)
|
|
@@ -68,7 +68,7 @@ function focusPhoto(i: number) {
|
|
|
68
68
|
<GinkoImage
|
|
69
69
|
:image="image"
|
|
70
70
|
:sizes="sizes"
|
|
71
|
-
:eager="stack && i === 0"
|
|
71
|
+
:eager="(stack || eager) && i === 0"
|
|
72
72
|
class="w-full"
|
|
73
73
|
:class="!stack && ['aspect-3/2 object-cover', rows && 'sm:aspect-(--ratio)']"
|
|
74
74
|
/>
|
|
@@ -4,18 +4,19 @@ import type { BlogPost } from '~/composables/useBlog'
|
|
|
4
4
|
|
|
5
5
|
/** Posts as rows divided by lines: date and authors, title (the link), summary, and the cover (3:2) at the end of the
|
|
6
6
|
* row from `sm` up, above the text on phones. A post without a cover is a row without an image, not a gap. `images` false
|
|
7
|
-
* leaves the covers out (a teaser on a home page). */
|
|
8
|
-
withDefaults(defineProps<{ items: BlogPost[], headingLevel?: 'h2' | 'h3', images?: boolean }>(), { headingLevel: 'h2', images: true })
|
|
7
|
+
* leaves the covers out (a teaser on a home page). `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
8
|
+
withDefaults(defineProps<{ items: BlogPost[], headingLevel?: 'h2' | 'h3', images?: boolean, eager?: boolean }>(), { headingLevel: 'h2', images: true })
|
|
9
9
|
const { format, t } = useI18n('blog')
|
|
10
10
|
</script>
|
|
11
11
|
|
|
12
12
|
<template>
|
|
13
13
|
<ul class="flex flex-col border-b">
|
|
14
|
-
<li v-for="post in items" :key="post.id" class="group relative flex flex-col gap-5 border-t py-8 sm:flex-row-reverse sm:items-start sm:gap-8">
|
|
14
|
+
<li v-for="(post, i) in items" :key="post.id" class="group relative flex flex-col gap-5 border-t py-8 sm:flex-row-reverse sm:items-start sm:gap-8">
|
|
15
15
|
<GinkoImage
|
|
16
16
|
v-if="images && post.image"
|
|
17
17
|
:image="post.image"
|
|
18
18
|
sizes="(min-width: 640px) 224px, 100vw"
|
|
19
|
+
:eager="eager && i === 0"
|
|
19
20
|
class="aspect-3/2 w-full shrink-0 rounded-block bg-muted object-cover sm:w-56"
|
|
20
21
|
/>
|
|
21
22
|
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
<script setup lang="ts">
|
|
3
3
|
/** People as a grid: photo (4:5) or initials, name (a link to the person's page when people have pages), role, short
|
|
4
4
|
* bio and contact. Never a stock face for a named person: without a photo the initials stand in, on phones small and
|
|
5
|
-
* beside the name, so the bios stay close. */
|
|
6
|
-
defineProps<{ items: GinkoItem<'people'>[] }>()
|
|
5
|
+
* beside the name, so the bios stay close. `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
6
|
+
defineProps<{ items: GinkoItem<'people'>[], eager?: boolean }>()
|
|
7
7
|
const { t } = useI18n('people')
|
|
8
8
|
const locale = useGinkoLocale()
|
|
9
9
|
</script>
|
|
10
10
|
|
|
11
11
|
<template>
|
|
12
12
|
<ul class="grid gap-x-10 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
|
|
13
|
-
<li v-for="person in items" :id="person.id.split('/').pop()" :key="person.id" class="flex flex-col gap-4 break-inside-avoid">
|
|
13
|
+
<li v-for="(person, i) in items" :id="person.id.split('/').pop()" :key="person.id" class="flex flex-col gap-4 break-inside-avoid">
|
|
14
14
|
<GinkoImage
|
|
15
15
|
v-if="person.data.photo"
|
|
16
|
+
:eager="eager && i === 0"
|
|
16
17
|
:image="person.data.photo"
|
|
17
18
|
sizes="(min-width: 1024px) 384px, (min-width: 640px) 50vw, 100vw"
|
|
18
19
|
class="aspect-4/5 w-full rounded-block bg-muted object-cover"
|
|
@@ -40,10 +40,10 @@ usePageSeo(() => ({
|
|
|
40
40
|
<template v-if="years.length > 1">
|
|
41
41
|
<section v-for="group in years" :key="group.year" :aria-labelledby="`year-${group.year}`" class="pt-6 first:pt-0">
|
|
42
42
|
<h2 :id="`year-${group.year}`" class="pb-2 text-h3 tabular-nums">{{ group.year }}</h2>
|
|
43
|
-
<BlogPostList :items="group.posts" heading-level="h3" />
|
|
43
|
+
<BlogPostList :items="group.posts" heading-level="h3" :eager="group === years[0]" />
|
|
44
44
|
</section>
|
|
45
45
|
</template>
|
|
46
|
-
<BlogPostList v-else-if="posts.length" :items="posts" />
|
|
46
|
+
<BlogPostList v-else-if="posts.length" :items="posts" eager />
|
|
47
47
|
<BlockEmpty v-else :title="t('empty')" path="content/posts/" />
|
|
48
48
|
<a :href="feed" class="mt-6 inline-flex min-h-11 items-center gap-2 text-small text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
|
|
49
49
|
<Rss class="size-4" aria-hidden="true" />{{ t('feed') }}
|
|
@@ -31,7 +31,7 @@ usePageSeo(() => ({ title: title.value, description: t('count', { count: tag.val
|
|
|
31
31
|
<BlockPageHeader :title="title" :meta="t('count', { count: tag.count })" />
|
|
32
32
|
<div class="mx-auto max-w-page px-gutter">
|
|
33
33
|
<div class="max-w-4xl">
|
|
34
|
-
<BlogPostList :items="tagged" />
|
|
34
|
+
<BlogPostList :items="tagged" eager />
|
|
35
35
|
<NuxtLink :to="path('news')" class="mt-6 inline-flex min-h-11 items-center gap-2 font-medium text-primary underline-offset-4 hover:underline">
|
|
36
36
|
{{ t('all') }}<ArrowRight class="size-4 rtl:-scale-x-100" aria-hidden="true" />
|
|
37
37
|
</NuxtLink>
|
|
@@ -17,7 +17,7 @@ usePageSeo(() => ({ title: t('title'), description: t('lead') }))
|
|
|
17
17
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
18
18
|
<div class="mx-auto max-w-page px-gutter">
|
|
19
19
|
<component :is="List" v-if="List && projects.items.length" :items="projects.items" />
|
|
20
|
-
<BlockCards v-else-if="cards.length" :items="cards" heading-level="h2" />
|
|
20
|
+
<BlockCards v-else-if="cards.length" :items="cards" heading-level="h2" eager />
|
|
21
21
|
<BlockEmpty v-else :title="t('empty')" path="content/projects/" />
|
|
22
22
|
</div>
|
|
23
23
|
</div>
|
|
@@ -33,7 +33,7 @@ usePageSeo(() => ({
|
|
|
33
33
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
34
34
|
<div class="mx-auto flex max-w-page flex-col gap-12 px-gutter md:gap-16">
|
|
35
35
|
<component :is="contact" v-if="contact" />
|
|
36
|
-
<PeopleList v-if="people.items.length" :items="people.items" />
|
|
36
|
+
<PeopleList v-if="people.items.length" :items="people.items" eager />
|
|
37
37
|
<BlockEmpty v-else :title="t('empty')" path="content/people/" />
|
|
38
38
|
</div>
|
|
39
39
|
</div>
|
|
@@ -13,6 +13,8 @@ export default defineNuxtConfig({
|
|
|
13
13
|
'shadcn-nuxt',
|
|
14
14
|
],
|
|
15
15
|
ginko,
|
|
16
|
+
// The site's icon: the initial on the brand colour. Replace public/favicon.svg with the real mark.
|
|
17
|
+
app: { head: { link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }] } },
|
|
16
18
|
css: ['~/assets/css/main.css'],
|
|
17
19
|
// Light and dark follow the visitor's system until they pick one with the toggle; `.dark` on <html>.
|
|
18
20
|
colorMode: { classSuffix: '', preference: 'system', fallback: 'light', storageKey: 'color-mode' },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#1c1917"/><text x="16" y="22.5" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="19" font-weight="700" fill="#fff">M</text></svg>
|
|
@@ -16,8 +16,9 @@ export interface Card {
|
|
|
16
16
|
/** Two to four cards per row; the whole card is one link (the title's link stretches over it), marked by an arrow.
|
|
17
17
|
* Photos are 3:2. `headingLevel`: h3 under a section's h2 (default), h2 when the cards follow the page's h1.
|
|
18
18
|
* `compact`: on phones (below `sm`) each card is one row, a square photo of 96 px at the start next to the meta line
|
|
19
|
-
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
-
|
|
19
|
+
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
+
* `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
21
|
+
const props = withDefaults(defineProps<{ items: Card[], columns?: 2 | 3 | 4, headingLevel?: 'h2' | 'h3', compact?: boolean, eager?: boolean }>(), { columns: 3, headingLevel: 'h3' })
|
|
21
22
|
// Never an empty column at lg: fewer items than `columns` take that many columns (two at least; one item stays as is).
|
|
22
23
|
const lgColumns = computed(() => (props.items.length > 1 ? Math.min(props.columns, Math.max(2, props.items.length)) : props.columns))
|
|
23
24
|
const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw, ${props.compact ? '96px' : '100vw'}`)
|
|
@@ -29,7 +30,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
29
30
|
:class="{ 'lg:grid-cols-3': lgColumns === 3, 'lg:grid-cols-4': lgColumns === 4, 'max-sm:gap-3': compact }"
|
|
30
31
|
>
|
|
31
32
|
<li
|
|
32
|
-
v-for="item in items"
|
|
33
|
+
v-for="(item, i) in items"
|
|
33
34
|
:key="item.key"
|
|
34
35
|
class="group relative flex overflow-hidden rounded-block border bg-card transition-[color,background-color,border-color] duration-150 hover:bg-muted has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-3 has-[a:focus-visible]:outline-ring"
|
|
35
36
|
:class="compact ? 'max-sm:items-center sm:flex-col' : 'flex-col'"
|
|
@@ -38,6 +39,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
38
39
|
v-if="item.image"
|
|
39
40
|
:image="item.image"
|
|
40
41
|
:sizes="sizes"
|
|
42
|
+
:eager="eager && i === 0"
|
|
41
43
|
class="aspect-3/2 w-full bg-muted object-cover"
|
|
42
44
|
:class="{ 'max-sm:m-3 max-sm:me-0 max-sm:aspect-square max-sm:size-24 max-sm:shrink-0 max-sm:rounded-md': compact }"
|
|
43
45
|
/>
|
|
@@ -9,10 +9,10 @@ import type { ImageValue } from '@lupinum/ginko'
|
|
|
9
9
|
* takes the rest of a short last row, so its photos keep about the row height instead of growing past the rows above.
|
|
10
10
|
* Each photo is a button that opens the lightbox at that photo.
|
|
11
11
|
* `stack`: one photo below the other at full width, each in its own ratio, the first loaded at once (the main photos of
|
|
12
|
-
* a page, such as the photos of one project). A photo's description (`alt`) is not shown below it; it stays on the
|
|
12
|
+
* a page, such as the photos of one project). `eager`: the gallery opens the page, so its first photo loads at once. A photo's description (`alt`) is not shown below it; it stays on the
|
|
13
13
|
* image and in the name of the photo's button.
|
|
14
14
|
*/
|
|
15
|
-
const props = defineProps<{ images: ImageValue[], stack?: boolean }>()
|
|
15
|
+
const props = defineProps<{ images: ImageValue[], stack?: boolean, eager?: boolean }>()
|
|
16
16
|
const { t } = useI18n('gallery')
|
|
17
17
|
const list = useTemplateRef<HTMLUListElement>('list')
|
|
18
18
|
const open = ref(false)
|
|
@@ -68,7 +68,7 @@ function focusPhoto(i: number) {
|
|
|
68
68
|
<GinkoImage
|
|
69
69
|
:image="image"
|
|
70
70
|
:sizes="sizes"
|
|
71
|
-
:eager="stack && i === 0"
|
|
71
|
+
:eager="(stack || eager) && i === 0"
|
|
72
72
|
class="w-full"
|
|
73
73
|
:class="!stack && ['aspect-3/2 object-cover', rows && 'sm:aspect-(--ratio)']"
|
|
74
74
|
/>
|
|
@@ -13,7 +13,7 @@ usePageSeo(() => ({ title: t('title'), description: t('lead') }))
|
|
|
13
13
|
<BlockPageHeader :title="t('title')" :lead="t('lead')" />
|
|
14
14
|
<div class="mx-auto max-w-page px-gutter">
|
|
15
15
|
<h2 v-if="cards.length" class="sr-only">{{ t('upcoming') }}</h2>
|
|
16
|
-
<BlockCards v-if="cards.length" :items="cards" :columns="3" />
|
|
16
|
+
<BlockCards v-if="cards.length" :items="cards" :columns="3" eager />
|
|
17
17
|
<p v-else class="max-w-prose text-lead text-muted-foreground">{{ t('none') }}</p>
|
|
18
18
|
</div>
|
|
19
19
|
</div>
|
|
@@ -10,7 +10,7 @@ usePageSeo(() => ({ title: t('gallery'), description: t('galleryLead'), image: i
|
|
|
10
10
|
<div>
|
|
11
11
|
<BlockPageHeader :title="t('gallery')" :lead="t('galleryLead')" />
|
|
12
12
|
<div class="mx-auto max-w-page px-gutter">
|
|
13
|
-
<BlockGallery v-if="images.length" :images="images" />
|
|
13
|
+
<BlockGallery v-if="images.length" :images="images" eager />
|
|
14
14
|
<BlockEmpty v-else :title="t('galleryEmpty')" path="content/gallery.md" />
|
|
15
15
|
</div>
|
|
16
16
|
</div>
|
|
@@ -13,6 +13,8 @@ export default defineNuxtConfig({
|
|
|
13
13
|
'shadcn-nuxt',
|
|
14
14
|
],
|
|
15
15
|
ginko,
|
|
16
|
+
// The site's icon: the initial on the brand colour. Replace public/favicon.svg with the real mark.
|
|
17
|
+
app: { head: { link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }] } },
|
|
16
18
|
css: ['~/assets/css/main.css'],
|
|
17
19
|
// Light and dark follow the visitor's system until they pick one with the toggle; `.dark` on <html>.
|
|
18
20
|
colorMode: { classSuffix: '', preference: 'system', fallback: 'light', storageKey: 'color-mode' },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#c2410c"/><text x="16" y="22.5" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="19" font-weight="700" fill="#fff">L</text></svg>
|
|
@@ -16,8 +16,9 @@ export interface Card {
|
|
|
16
16
|
/** Two to four cards per row; the whole card is one link (the title's link stretches over it), marked by an arrow.
|
|
17
17
|
* Photos are 3:2. `headingLevel`: h3 under a section's h2 (default), h2 when the cards follow the page's h1.
|
|
18
18
|
* `compact`: on phones (below `sm`) each card is one row, a square photo of 96 px at the start next to the meta line
|
|
19
|
-
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
-
|
|
19
|
+
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
+
* `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
21
|
+
const props = withDefaults(defineProps<{ items: Card[], columns?: 2 | 3 | 4, headingLevel?: 'h2' | 'h3', compact?: boolean, eager?: boolean }>(), { columns: 3, headingLevel: 'h3' })
|
|
21
22
|
// Never an empty column at lg: fewer items than `columns` take that many columns (two at least; one item stays as is).
|
|
22
23
|
const lgColumns = computed(() => (props.items.length > 1 ? Math.min(props.columns, Math.max(2, props.items.length)) : props.columns))
|
|
23
24
|
const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw, ${props.compact ? '96px' : '100vw'}`)
|
|
@@ -29,7 +30,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
29
30
|
:class="{ 'lg:grid-cols-3': lgColumns === 3, 'lg:grid-cols-4': lgColumns === 4, 'max-sm:gap-3': compact }"
|
|
30
31
|
>
|
|
31
32
|
<li
|
|
32
|
-
v-for="item in items"
|
|
33
|
+
v-for="(item, i) in items"
|
|
33
34
|
:key="item.key"
|
|
34
35
|
class="group relative flex overflow-hidden rounded-block border bg-card transition-[color,background-color,border-color] duration-150 hover:bg-muted has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-3 has-[a:focus-visible]:outline-ring"
|
|
35
36
|
:class="compact ? 'max-sm:items-center sm:flex-col' : 'flex-col'"
|
|
@@ -38,6 +39,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
38
39
|
v-if="item.image"
|
|
39
40
|
:image="item.image"
|
|
40
41
|
:sizes="sizes"
|
|
42
|
+
:eager="eager && i === 0"
|
|
41
43
|
class="aspect-3/2 w-full bg-muted object-cover"
|
|
42
44
|
:class="{ 'max-sm:m-3 max-sm:me-0 max-sm:aspect-square max-sm:size-24 max-sm:shrink-0 max-sm:rounded-md': compact }"
|
|
43
45
|
/>
|
|
@@ -4,18 +4,19 @@ import type { BlogPost } from '~/composables/useBlog'
|
|
|
4
4
|
|
|
5
5
|
/** Posts as rows divided by lines: date and authors, title (the link), summary, and the cover (3:2) at the end of the
|
|
6
6
|
* row from `sm` up, above the text on phones. A post without a cover is a row without an image, not a gap. `images` false
|
|
7
|
-
* leaves the covers out (a teaser on a home page). */
|
|
8
|
-
withDefaults(defineProps<{ items: BlogPost[], headingLevel?: 'h2' | 'h3', images?: boolean }>(), { headingLevel: 'h2', images: true })
|
|
7
|
+
* leaves the covers out (a teaser on a home page). `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
8
|
+
withDefaults(defineProps<{ items: BlogPost[], headingLevel?: 'h2' | 'h3', images?: boolean, eager?: boolean }>(), { headingLevel: 'h2', images: true })
|
|
9
9
|
const { format, t } = useI18n('blog')
|
|
10
10
|
</script>
|
|
11
11
|
|
|
12
12
|
<template>
|
|
13
13
|
<ul class="flex flex-col border-b">
|
|
14
|
-
<li v-for="post in items" :key="post.id" class="group relative flex flex-col gap-5 border-t py-8 sm:flex-row-reverse sm:items-start sm:gap-8">
|
|
14
|
+
<li v-for="(post, i) in items" :key="post.id" class="group relative flex flex-col gap-5 border-t py-8 sm:flex-row-reverse sm:items-start sm:gap-8">
|
|
15
15
|
<GinkoImage
|
|
16
16
|
v-if="images && post.image"
|
|
17
17
|
:image="post.image"
|
|
18
18
|
sizes="(min-width: 640px) 224px, 100vw"
|
|
19
|
+
:eager="eager && i === 0"
|
|
19
20
|
class="aspect-3/2 w-full shrink-0 rounded-block bg-muted object-cover sm:w-56"
|
|
20
21
|
/>
|
|
21
22
|
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
|
@@ -34,7 +34,7 @@ usePageSeo(() => ({ title: t('title'), description: lead.value }))
|
|
|
34
34
|
<time :datetime="release.date">{{ format.date(release.date) }}</time>
|
|
35
35
|
</p>
|
|
36
36
|
</div>
|
|
37
|
-
<GinkoMarkdown :value="release.body" class="prose min-w-0 lg:col-span-8" />
|
|
37
|
+
<GinkoMarkdown :value="release.body" :id-prefix="release.anchor" class="prose min-w-0 lg:col-span-8" />
|
|
38
38
|
</li>
|
|
39
39
|
</ul>
|
|
40
40
|
<BlockEmpty v-else :title="t('empty')" path="content/changelog/" />
|
|
@@ -40,10 +40,10 @@ usePageSeo(() => ({
|
|
|
40
40
|
<template v-if="years.length > 1">
|
|
41
41
|
<section v-for="group in years" :key="group.year" :aria-labelledby="`year-${group.year}`" class="pt-6 first:pt-0">
|
|
42
42
|
<h2 :id="`year-${group.year}`" class="pb-2 text-h3 tabular-nums">{{ group.year }}</h2>
|
|
43
|
-
<BlogPostList :items="group.posts" heading-level="h3" />
|
|
43
|
+
<BlogPostList :items="group.posts" heading-level="h3" :eager="group === years[0]" />
|
|
44
44
|
</section>
|
|
45
45
|
</template>
|
|
46
|
-
<BlogPostList v-else-if="posts.length" :items="posts" />
|
|
46
|
+
<BlogPostList v-else-if="posts.length" :items="posts" eager />
|
|
47
47
|
<BlockEmpty v-else :title="t('empty')" path="content/posts/" />
|
|
48
48
|
<a :href="feed" class="mt-6 inline-flex min-h-11 items-center gap-2 text-small text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
|
|
49
49
|
<Rss class="size-4" aria-hidden="true" />{{ t('feed') }}
|
|
@@ -31,7 +31,7 @@ usePageSeo(() => ({ title: title.value, description: t('count', { count: tag.val
|
|
|
31
31
|
<BlockPageHeader :title="title" :meta="t('count', { count: tag.count })" />
|
|
32
32
|
<div class="mx-auto max-w-page px-gutter">
|
|
33
33
|
<div class="max-w-4xl">
|
|
34
|
-
<BlogPostList :items="tagged" />
|
|
34
|
+
<BlogPostList :items="tagged" eager />
|
|
35
35
|
<NuxtLink :to="path('news')" class="mt-6 inline-flex min-h-11 items-center gap-2 font-medium text-primary underline-offset-4 hover:underline">
|
|
36
36
|
{{ t('all') }}<ArrowRight class="size-4 rtl:-scale-x-100" aria-hidden="true" />
|
|
37
37
|
</NuxtLink>
|
|
@@ -14,6 +14,8 @@ export default defineNuxtConfig({
|
|
|
14
14
|
'shadcn-nuxt',
|
|
15
15
|
],
|
|
16
16
|
ginko,
|
|
17
|
+
// The site's icon: the initial on the brand colour. Replace public/favicon.svg with the real mark.
|
|
18
|
+
app: { head: { link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }] } },
|
|
17
19
|
css: ['~/assets/css/main.css'],
|
|
18
20
|
// Light and dark follow the visitor's system until they pick one with the toggle; `.dark` on <html>.
|
|
19
21
|
colorMode: { classSuffix: '', preference: 'system', fallback: 'light', storageKey: 'color-mode' },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#6d28d9"/><text x="16" y="22.5" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="19" font-weight="700" fill="#fff">P</text></svg>
|
|
@@ -16,8 +16,9 @@ export interface Card {
|
|
|
16
16
|
/** Two to four cards per row; the whole card is one link (the title's link stretches over it), marked by an arrow.
|
|
17
17
|
* Photos are 3:2. `headingLevel`: h3 under a section's h2 (default), h2 when the cards follow the page's h1.
|
|
18
18
|
* `compact`: on phones (below `sm`) each card is one row, a square photo of 96 px at the start next to the meta line
|
|
19
|
-
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
-
|
|
19
|
+
* and the title, without the text and the arrow, so a list of three fits in less than a screen.
|
|
20
|
+
* `eager`: the list opens the page, so the first photo loads at once (it is the largest thing on a phone's first screen). */
|
|
21
|
+
const props = withDefaults(defineProps<{ items: Card[], columns?: 2 | 3 | 4, headingLevel?: 'h2' | 'h3', compact?: boolean, eager?: boolean }>(), { columns: 3, headingLevel: 'h3' })
|
|
21
22
|
// Never an empty column at lg: fewer items than `columns` take that many columns (two at least; one item stays as is).
|
|
22
23
|
const lgColumns = computed(() => (props.items.length > 1 ? Math.min(props.columns, Math.max(2, props.items.length)) : props.columns))
|
|
23
24
|
const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw, ${props.compact ? '96px' : '100vw'}`)
|
|
@@ -29,7 +30,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
29
30
|
:class="{ 'lg:grid-cols-3': lgColumns === 3, 'lg:grid-cols-4': lgColumns === 4, 'max-sm:gap-3': compact }"
|
|
30
31
|
>
|
|
31
32
|
<li
|
|
32
|
-
v-for="item in items"
|
|
33
|
+
v-for="(item, i) in items"
|
|
33
34
|
:key="item.key"
|
|
34
35
|
class="group relative flex overflow-hidden rounded-block border bg-card transition-[color,background-color,border-color] duration-150 hover:bg-muted has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-3 has-[a:focus-visible]:outline-ring"
|
|
35
36
|
:class="compact ? 'max-sm:items-center sm:flex-col' : 'flex-col'"
|
|
@@ -38,6 +39,7 @@ const sizes = computed(() => `(min-width: 1024px) 384px, (min-width: 640px) 50vw
|
|
|
38
39
|
v-if="item.image"
|
|
39
40
|
:image="item.image"
|
|
40
41
|
:sizes="sizes"
|
|
42
|
+
:eager="eager && i === 0"
|
|
41
43
|
class="aspect-3/2 w-full bg-muted object-cover"
|
|
42
44
|
:class="{ 'max-sm:m-3 max-sm:me-0 max-sm:aspect-square max-sm:size-24 max-sm:shrink-0 max-sm:rounded-md': compact }"
|
|
43
45
|
/>
|
|
@@ -13,6 +13,8 @@ export default defineNuxtConfig({
|
|
|
13
13
|
'shadcn-nuxt',
|
|
14
14
|
],
|
|
15
15
|
ginko,
|
|
16
|
+
// The site's icon: the initial on the brand colour. Replace public/favicon.svg with the real mark.
|
|
17
|
+
app: { head: { link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }] } },
|
|
16
18
|
css: ['~/assets/css/main.css'],
|
|
17
19
|
// Light and dark follow the visitor's system until they pick one with the toggle; `.dark` on <html>.
|
|
18
20
|
colorMode: { classSuffix: '', preference: 'system', fallback: 'light', storageKey: 'color-mode' },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#18181b"/><text x="16" y="22.5" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="19" font-weight="700" fill="#fff">K</text></svg>
|