@codecavepro/brand 2.0.1 → 2.1.0
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/README.md +12 -8
- package/dist/src/components/common/ArticlePreview.vue +61 -0
- package/dist/src/components/common/BrandNav.vue +96 -23
- package/dist/src/components/footer/link-group.vue +36 -0
- package/dist/src/components/header/mobile-menu.vue +136 -0
- package/dist/src/components/header/services-list.vue +36 -0
- package/dist/src/components/homepage/technologies.vue +135 -0
- package/dist/src/components/homepage/technology-card.vue +81 -0
- package/dist/src/helpers/breakpoints.ts +5 -0
- package/dist/src/helpers/carousel-gesture-locker.ts +72 -0
- package/dist/src/helpers/date-formatter.ts +10 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -149,18 +149,22 @@ Two things to know about it:
|
|
|
149
149
|
|
|
150
150
|
## The components
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
themselves, the
|
|
152
|
+
19 components and 13 icons — the buttons, the form controls and the forms
|
|
153
|
+
themselves, the nav bar, the footer link group, the article and technology
|
|
154
|
+
cards, the review card and the typing effect codecave.pro renders — ship as
|
|
154
155
|
**source**, byte-for-byte the files the site builds from. Not a reimplementation
|
|
155
156
|
of them, and not a bundle compiled from a snapshot: the same files, kept
|
|
156
157
|
identical by a check that runs on every push.
|
|
157
158
|
|
|
158
|
-
**
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
items
|
|
159
|
+
**None of them knows your routes.** Six of these shipped for the first time in
|
|
160
|
+
2.1.0 and were held back before it, because each read codecave.pro's own route
|
|
161
|
+
table or menu data directly — installing one would have put that site's
|
|
162
|
+
navigation behind an npm release, so changing a menu item would mean publishing.
|
|
163
|
+
They now take the same information as props: `basePath` on `ArticlePreview`,
|
|
164
|
+
`items` on `link-group` and `services-list`, `serviceLinks` and `ctaHref` on
|
|
165
|
+
`technologies`, `href` on `technology-card`, `items` and `logo` on
|
|
166
|
+
`mobile-menu`. `BrandNav` was the first to be built that way and is the reason
|
|
167
|
+
the rest could follow.
|
|
164
168
|
|
|
165
169
|
```vue
|
|
166
170
|
<BrandNav
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { formattedDate } from "../../helpers/date-formatter.ts";
|
|
3
|
+
|
|
4
|
+
/* The fields this component actually reads, instead of the generated Strapi
|
|
5
|
+
* `Article`. TypeScript is structural, so a real Article still satisfies this
|
|
6
|
+
* -- callers pass exactly what they passed before -- but the component no
|
|
7
|
+
* longer carries the CMS schema with it. */
|
|
8
|
+
type ArticleSummary = {
|
|
9
|
+
slug: string | null
|
|
10
|
+
title: string
|
|
11
|
+
excerpt: string
|
|
12
|
+
date: Date | null
|
|
13
|
+
locale: string | null
|
|
14
|
+
readingtime: unknown
|
|
15
|
+
cover: { url: string; name: string }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const props = defineProps<{
|
|
19
|
+
article: ArticleSummary
|
|
20
|
+
className?: string
|
|
21
|
+
/* CMS media URLs arrive relative ("uploads/x.png"). The site injects its own
|
|
22
|
+
* resolver; a caller whose URLs are already absolute passes nothing. */
|
|
23
|
+
resolveImage?: (url: string) => string
|
|
24
|
+
/* Prefix the slug is appended to. Was `paths.insights`, read straight off
|
|
25
|
+
* this site's route table -- the last thing keeping this component from
|
|
26
|
+
* being installable. The caller owns where its articles live. */
|
|
27
|
+
basePath?: string
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<a :href="`${basePath ?? ''}${article.slug}/`" :class="`mx-1 lg:mx-2 w-full h-full self-start sm:self-auto p-6 flex flex-col gap-5 sm:gap-8
|
|
35
|
+
rounded-[2.25rem] bg-surface-secondary hover:bg-surface-secondary transition-colors cursor-pointer border-surface-tertiary border
|
|
36
|
+
${className ?? ''}`">
|
|
37
|
+
<div class="flex flex-col sm:flex-row gap-5 sm:gap-8 h-fit">
|
|
38
|
+
<img loading="lazy" class="sm:w-[132px] sm:h-[132px] rounded-xl object-cover"
|
|
39
|
+
:src="imageUrl(article.cover.url)"
|
|
40
|
+
:alt="article.cover.name"
|
|
41
|
+
:width="100"
|
|
42
|
+
:height="100"/>
|
|
43
|
+
<div class="space-y-2 sm:space-y-3">
|
|
44
|
+
<time class="text-body-secondary text-xs sm:text-sm" :datetime="article.date?.toString()">
|
|
45
|
+
{{ formattedDate(article.locale, article.date) }}
|
|
46
|
+
</time>
|
|
47
|
+
<h3 class="font-bold text-lg md:text-xl text-heading">
|
|
48
|
+
{{ article.title }}
|
|
49
|
+
</h3>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="text-sm flex flex-col justify-between h-full">
|
|
53
|
+
<p class="text-body-secondary-lighter">
|
|
54
|
+
{{ article.excerpt }}
|
|
55
|
+
</p>
|
|
56
|
+
<p class="text-xs sm:text-sm text-body-secondary mt-6 sm:mt-7">
|
|
57
|
+
Reading time: {{ article.readingtime }} m.
|
|
58
|
+
</p>
|
|
59
|
+
</div>
|
|
60
|
+
</a>
|
|
61
|
+
</template>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* The CODECAVE top bar. Ghost-
|
|
3
|
+
* The CODECAVE top bar. Ghost-style links split around a centred wordmark.
|
|
4
4
|
*
|
|
5
5
|
* AUTHORED, not captured. Every other component this package ships is a copy of
|
|
6
6
|
* a file codecave.pro builds from, and docs/source_examples/ holds nothing
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* into one, so neither repo owns it any more.
|
|
10
10
|
*
|
|
11
11
|
* WHY IT CAN SHIP WHEN THE MENUS COULD NOT. desktop-menu.vue and mobile-menu.vue
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* were excluded from this package (CCWEB2-371) because they reach the site
|
|
13
|
+
* menu.ts -- installing one would put codecave.pro navigation behind an npm
|
|
14
14
|
* release. This takes its items, its logo and its active state as PROPS, so it
|
|
15
15
|
* reaches nothing. That is the whole difference, and it is why the same bar can
|
|
16
16
|
* serve a marketing site and a documentation site without either one lending the
|
|
@@ -20,6 +20,17 @@
|
|
|
20
20
|
* HTML and ships no JavaScript, which is what the docs bar already does and what
|
|
21
21
|
* the site header does NOT -- it is `client:only`, so it renders nothing at all
|
|
22
22
|
* until Vue boots. The dropdown below is CSS hover for the same reason.
|
|
23
|
+
*
|
|
24
|
+
* IT NEEDS tokens.css AND NOTHING ELSE. The links are styled here rather than
|
|
25
|
+
* borrowed, and that is deliberate: the two ghost buttons this system already
|
|
26
|
+
* has DISAGREE, so there was nothing to borrow. colors_and_type.css's
|
|
27
|
+
* `.btn-ghost` renders 48px, because it cancels `height` while `.btn` sets
|
|
28
|
+
* `min-height`; Button.vue's `ghost` variant renders 40px, because it drops the
|
|
29
|
+
* `min-h-12` off its base class. Eight pixels, one of them a dead override of
|
|
30
|
+
* the kind CCWEB2-331 records elsewhere. Neither is portable either -- a docs
|
|
31
|
+
* page has the stylesheet and no Tailwind, a consumer site has Tailwind and not
|
|
32
|
+
* the stylesheet. Standing on tokens instead means this bar renders the same in
|
|
33
|
+
* both, and a consumer needs no global class layer to install it.
|
|
23
34
|
*/
|
|
24
35
|
export interface NavItem {
|
|
25
36
|
/** Link text, and the value `current` is matched against. */
|
|
@@ -28,6 +39,11 @@ export interface NavItem {
|
|
|
28
39
|
href?: string;
|
|
29
40
|
/** Names a slot to render as a hover dropdown under this item. */
|
|
30
41
|
slot?: string;
|
|
42
|
+
/** Draws the attention dot. codecave.pro puts it on "Contact us"; it used to
|
|
43
|
+
* be a `ul:last-of-type li:last-child::after` rule, which meant the marker
|
|
44
|
+
* belonged to a POSITION rather than to an item and moved on its own the
|
|
45
|
+
* first time the menu was reordered. Naming the item is the fix. */
|
|
46
|
+
badge?: boolean;
|
|
31
47
|
}
|
|
32
48
|
|
|
33
49
|
withDefaults(defineProps<{
|
|
@@ -39,7 +55,14 @@ withDefaults(defineProps<{
|
|
|
39
55
|
* its own asset pipeline, and this package ships no brand marks. */
|
|
40
56
|
logo: string;
|
|
41
57
|
logoHref?: string;
|
|
58
|
+
/** Alt text of the wordmark image. */
|
|
42
59
|
logoAlt?: string;
|
|
60
|
+
/** Accessible name of the wordmark LINK, when it should differ from the alt
|
|
61
|
+
* text -- "CODECAVE design system, documentation home" reads better on a
|
|
62
|
+
* home link than the wordmark does. Left off, the link is named by the image
|
|
63
|
+
* alt, which is the right default and the reason this is not simply
|
|
64
|
+
* `logoAlt` twice. */
|
|
65
|
+
logoLabel?: string;
|
|
43
66
|
/** `name` of the item to mark aria-current="page". */
|
|
44
67
|
current?: string;
|
|
45
68
|
}>(), {
|
|
@@ -54,33 +77,33 @@ withDefaults(defineProps<{
|
|
|
54
77
|
<nav class="brand-nav" aria-label="Main">
|
|
55
78
|
<div class="brand-nav-inner">
|
|
56
79
|
<ul>
|
|
57
|
-
<li v-for="item in left" :key="item.name" :class="{ dropdown: item.slot }">
|
|
80
|
+
<li v-for="item in left" :key="item.name" :class="{ dropdown: item.slot, badged: item.badge }">
|
|
58
81
|
<a
|
|
59
82
|
v-if="item.href"
|
|
60
|
-
class="
|
|
83
|
+
class="brand-nav-link"
|
|
61
84
|
:href="item.href"
|
|
62
|
-
:aria-current="item.name === current ?
|
|
85
|
+
:aria-current="item.name === current ? 'page' : undefined"
|
|
63
86
|
>{{ item.name }}</a>
|
|
64
|
-
<span v-else class="
|
|
87
|
+
<span v-else class="brand-nav-link dropbtn">{{ item.name }}<slot name="chevron" /></span>
|
|
65
88
|
<div v-if="item.slot" class="dropdown-content">
|
|
66
89
|
<slot :name="item.slot" />
|
|
67
90
|
</div>
|
|
68
91
|
</li>
|
|
69
92
|
</ul>
|
|
70
93
|
|
|
71
|
-
<a class="brand-nav-logo" :href="logoHref" :aria-label="
|
|
94
|
+
<a class="brand-nav-logo" :href="logoHref" :aria-label="logoLabel">
|
|
72
95
|
<img :src="logo" :alt="logoAlt" />
|
|
73
96
|
</a>
|
|
74
97
|
|
|
75
98
|
<ul>
|
|
76
|
-
<li v-for="item in right" :key="item.name" :class="{ dropdown: item.slot }">
|
|
99
|
+
<li v-for="item in right" :key="item.name" :class="{ dropdown: item.slot, badged: item.badge }">
|
|
77
100
|
<a
|
|
78
101
|
v-if="item.href"
|
|
79
|
-
class="
|
|
102
|
+
class="brand-nav-link"
|
|
80
103
|
:href="item.href"
|
|
81
|
-
:aria-current="item.name === current ?
|
|
104
|
+
:aria-current="item.name === current ? 'page' : undefined"
|
|
82
105
|
>{{ item.name }}</a>
|
|
83
|
-
<span v-else class="
|
|
106
|
+
<span v-else class="brand-nav-link dropbtn">{{ item.name }}<slot name="chevron" /></span>
|
|
84
107
|
<div v-if="item.slot" class="dropdown-content">
|
|
85
108
|
<slot :name="item.slot" />
|
|
86
109
|
</div>
|
|
@@ -91,16 +114,11 @@ withDefaults(defineProps<{
|
|
|
91
114
|
</template>
|
|
92
115
|
|
|
93
116
|
<style scoped>
|
|
94
|
-
/*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* The three values that used to differ between the site bar and the docs bar are
|
|
99
|
-
* settled here on the docs side: 16px links on the 48px --control-height grid,
|
|
100
|
-
* a 28px wordmark, and the 1px rule underneath. The site had shrunk all three
|
|
101
|
-
* with a text-sm wrapper, which quietly took its ghost buttons off the control
|
|
102
|
-
* grid every other control sits on. */
|
|
117
|
+
/* Every colour and nearly every length below is a token from tokens.css. The
|
|
118
|
+
* literals that remain are the link padding and the blur radius, neither of
|
|
119
|
+
* which the token set names. */
|
|
103
120
|
.brand-nav {
|
|
121
|
+
--brand-nav-pad: 0.75rem; /* the site header's py-3 */
|
|
104
122
|
position: sticky;
|
|
105
123
|
top: 0;
|
|
106
124
|
z-index: 100;
|
|
@@ -111,14 +129,28 @@ withDefaults(defineProps<{
|
|
|
111
129
|
}
|
|
112
130
|
|
|
113
131
|
.brand-nav-inner {
|
|
114
|
-
box-sizing: border-box;
|
|
132
|
+
box-sizing: border-box; /* the site resets this globally; a docs page does not */
|
|
115
133
|
position: relative;
|
|
116
134
|
display: flex;
|
|
117
135
|
align-items: center;
|
|
118
136
|
justify-content: space-between;
|
|
119
137
|
max-width: var(--max-width-desktop);
|
|
120
138
|
margin: 0 auto;
|
|
121
|
-
|
|
139
|
+
/* Pins the row, so the bar's height follows --control-height rather than
|
|
140
|
+
whatever the links happen to measure. */
|
|
141
|
+
min-height: calc(var(--control-height) + 2 * var(--brand-nav-pad));
|
|
142
|
+
padding: var(--brand-nav-pad) var(--gutter-base);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* The page gutter, stepped at the same two breakpoints .page-container steps at.
|
|
146
|
+
It is repeated here rather than read off --gutter-base because that token does
|
|
147
|
+
not change with the viewport -- the class switches to a different token
|
|
148
|
+
instead. A bar that skipped this sat 20px inboard of every heading it topped. */
|
|
149
|
+
@media (min-width: 768px) {
|
|
150
|
+
.brand-nav-inner { padding-left: var(--gutter-md); padding-right: var(--gutter-md); }
|
|
151
|
+
}
|
|
152
|
+
@media (min-width: 1280px) {
|
|
153
|
+
.brand-nav-inner { padding-left: var(--gutter-xl); padding-right: var(--gutter-xl); }
|
|
122
154
|
}
|
|
123
155
|
|
|
124
156
|
.brand-nav ul {
|
|
@@ -128,6 +160,47 @@ withDefaults(defineProps<{
|
|
|
128
160
|
padding: 0;
|
|
129
161
|
list-style: none;
|
|
130
162
|
}
|
|
163
|
+
.brand-nav li { margin: 0; }
|
|
164
|
+
|
|
165
|
+
/* The ghost button, declared rather than inherited. See the note in the script
|
|
166
|
+
block: the two that exist disagree by 8px, and neither is available in both of
|
|
167
|
+
the places this bar has to render. */
|
|
168
|
+
.brand-nav-link {
|
|
169
|
+
box-sizing: border-box;
|
|
170
|
+
display: inline-flex;
|
|
171
|
+
align-items: center;
|
|
172
|
+
justify-content: center;
|
|
173
|
+
min-height: var(--control-height);
|
|
174
|
+
padding: 0.5rem 1.5rem;
|
|
175
|
+
border-radius: var(--radius-pill);
|
|
176
|
+
color: var(--color-body-primary);
|
|
177
|
+
font-family: var(--font-sans);
|
|
178
|
+
font-size: var(--text-body);
|
|
179
|
+
font-weight: var(--font-weight-bold);
|
|
180
|
+
line-height: var(--leading-body);
|
|
181
|
+
text-decoration: none;
|
|
182
|
+
white-space: nowrap;
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
transition: color var(--transition-colors);
|
|
185
|
+
}
|
|
186
|
+
.brand-nav-link:hover,
|
|
187
|
+
.brand-nav-link:active { color: var(--color-hovered); }
|
|
188
|
+
|
|
189
|
+
/* The attention dot. Sized and placed against the 48px row rather than the 40px
|
|
190
|
+
one the site drew it on, so it stays clear of the pill's right edge. */
|
|
191
|
+
.badged { position: relative; }
|
|
192
|
+
.badged::after {
|
|
193
|
+
content: "";
|
|
194
|
+
position: absolute;
|
|
195
|
+
top: 15px;
|
|
196
|
+
right: 8px;
|
|
197
|
+
width: 10px;
|
|
198
|
+
height: 10px;
|
|
199
|
+
border-radius: 50%;
|
|
200
|
+
background: var(--color-error-400);
|
|
201
|
+
box-shadow: 0 0 12px 0 var(--color-error-400),
|
|
202
|
+
0 0 4px 0 hsl(from var(--color-error-400) h s l / 0.5);
|
|
203
|
+
}
|
|
131
204
|
|
|
132
205
|
.brand-nav-logo {
|
|
133
206
|
position: absolute;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import Button from "../common/Button.vue";
|
|
3
|
+
|
|
4
|
+
/* Declared here rather than imported from ./links.ts, which is this site's
|
|
5
|
+
* footer content. TypeScript is structural, so every existing caller still
|
|
6
|
+
* passes exactly what it passed before -- links.ts's own `Link` satisfies this
|
|
7
|
+
* -- but the component no longer reaches out of itself for a two-field shape.
|
|
8
|
+
* Same reasoning as ArticlePreview.vue's ArticleSummary. */
|
|
9
|
+
type LinkItem = {
|
|
10
|
+
name: string
|
|
11
|
+
href: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
defineProps<{
|
|
15
|
+
groupName: string
|
|
16
|
+
items: LinkItem[]
|
|
17
|
+
}>()
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="space-y-4">
|
|
22
|
+
<p class="text-body-secondary uppercase font-bold text-xs">
|
|
23
|
+
{{ groupName }}
|
|
24
|
+
</p>
|
|
25
|
+
<div class="space-y-3 xl:space-y-2 text-sm">
|
|
26
|
+
<div :key="index" v-for="(item, index) in items">
|
|
27
|
+
<Button
|
|
28
|
+
:title="item.name"
|
|
29
|
+
as="link"
|
|
30
|
+
:href="item.href"
|
|
31
|
+
variant="text"
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import Button from "../common/Button.vue";
|
|
3
|
+
import Shevron from "../../assets/icons/shevron.vue";
|
|
4
|
+
import { computed, onUnmounted, ref, watch } from "vue";
|
|
5
|
+
import BackIcon from "../../assets/icons/back-icon.vue";
|
|
6
|
+
import ServicesList from "./services-list.vue";
|
|
7
|
+
|
|
8
|
+
/* Everything this drawer used to read out of menu.ts, paths.ts and the site's
|
|
9
|
+
* logo asset now arrives as props, so it reaches no route table and no asset
|
|
10
|
+
* pipeline of its own.
|
|
11
|
+
*
|
|
12
|
+
* The Services branch keys off `submenu` being present rather than off the
|
|
13
|
+
* label being the string "Services"; mobileTitle and emphasis carry the two
|
|
14
|
+
* other wordings that were hard-coded here. */
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
items: {
|
|
17
|
+
name: string
|
|
18
|
+
link?: string
|
|
19
|
+
submenuTitle?: string
|
|
20
|
+
submenu?: { icon: unknown; name: string; description: string; link: string }[]
|
|
21
|
+
mobileTitle?: string
|
|
22
|
+
emphasis?: boolean
|
|
23
|
+
}[]
|
|
24
|
+
/** Resolved URL of the wordmark. */
|
|
25
|
+
logo: string
|
|
26
|
+
homeHref?: string
|
|
27
|
+
}>()
|
|
28
|
+
|
|
29
|
+
const servicesItem = computed(() => props.items.find((i) => i.submenu?.length))
|
|
30
|
+
|
|
31
|
+
const isMenuOpen = ref(false)
|
|
32
|
+
const isServicesOpen = ref(false)
|
|
33
|
+
let scrollPosition = 0
|
|
34
|
+
|
|
35
|
+
const lockScroll = () => {
|
|
36
|
+
scrollPosition = window.scrollY || window.pageYOffset
|
|
37
|
+
|
|
38
|
+
document.body.style.overflow = 'hidden'
|
|
39
|
+
document.body.style.position = 'fixed'
|
|
40
|
+
document.body.style.top = `-${scrollPosition}px`
|
|
41
|
+
document.body.style.width = '100%'
|
|
42
|
+
|
|
43
|
+
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
|
44
|
+
if (scrollbarWidth > 0) {
|
|
45
|
+
document.body.style.paddingRight = `${scrollbarWidth}px`
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const unlockScroll = () => {
|
|
50
|
+
document.body.style.position = ''
|
|
51
|
+
document.body.style.top = ''
|
|
52
|
+
document.body.style.width = ''
|
|
53
|
+
document.body.style.paddingRight = ''
|
|
54
|
+
document.body.style.overflow = ''
|
|
55
|
+
window.scrollTo(0, scrollPosition)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
watch(isMenuOpen, (newVal) => {
|
|
59
|
+
if (newVal) {
|
|
60
|
+
lockScroll()
|
|
61
|
+
} else {
|
|
62
|
+
unlockScroll()
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
onUnmounted(() => {
|
|
67
|
+
if (isMenuOpen.value) {
|
|
68
|
+
unlockScroll()
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
const handleCloseMenu = () => {
|
|
72
|
+
isMenuOpen.value = false
|
|
73
|
+
}
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<div>
|
|
78
|
+
<div :class="`fixed z-50 top-0 w-full bg-surface-primary-transparent backdrop-blur-3xl ${isMenuOpen ? 'rounded-b-3xl' : 'rounded-none'}`">
|
|
79
|
+
<div :class="`h-full px-5 ${isMenuOpen ? 'pb-5' : 'pb-0'} flex flex-col gap-2 transition-all`">
|
|
80
|
+
<div class="flex justify-between items-center py-1.5">
|
|
81
|
+
<a :href="homeHref" class="px-1.5 hover:opacity-80 transition-opacity">
|
|
82
|
+
<img :src="logo" alt="CODECAVE" />
|
|
83
|
+
</a>
|
|
84
|
+
<button @click="isMenuOpen = !isMenuOpen" :class="`burger-menu ${isMenuOpen ? 'text-action' : 'text-heading'} transition-colors`">
|
|
85
|
+
<span></span>
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
<nav v-if="isMenuOpen" class="h-full text-sm">
|
|
89
|
+
<div v-if="isServicesOpen">
|
|
90
|
+
<h2 class="text-center font-bold text-body-primary py-2.5">
|
|
91
|
+
{{ servicesItem?.submenuTitle }}
|
|
92
|
+
</h2>
|
|
93
|
+
<ServicesList :items="servicesItem?.submenu ?? []" />
|
|
94
|
+
<button class="pt-4" @click="isServicesOpen = false">
|
|
95
|
+
<component class="w-11 h-11 text-action" :is="BackIcon" />
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
<ul v-else class="h-full flex flex-col items-center gap-3 justify-around px-4 pt-2">
|
|
99
|
+
<li v-for="(item, index) in items" :key="index">
|
|
100
|
+
<div v-if="item.submenu?.length">
|
|
101
|
+
<Button :title="item.name" variant="ghost" @click="isServicesOpen = true" class="transition-transform duration-150 pr-1">
|
|
102
|
+
<Shevron class="rotate-270 ml-1" />
|
|
103
|
+
</Button>
|
|
104
|
+
</div>
|
|
105
|
+
<Button v-else-if="item.emphasis" as="link" :href="item.link" :title="item.mobileTitle ?? item.name" variant="tertiary" @click="handleCloseMenu" />
|
|
106
|
+
<Button v-else as="link" variant="ghost" :title="item.mobileTitle ?? item.name" :href="item.link" />
|
|
107
|
+
</li>
|
|
108
|
+
</ul>
|
|
109
|
+
</nav>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
<div v-if="isMenuOpen" class="fixed z-0 inset-0" @click="handleCloseMenu">
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
116
|
+
|
|
117
|
+
<style scoped>
|
|
118
|
+
.burger-menu {
|
|
119
|
+
display: flex;
|
|
120
|
+
justify-content: space-around;
|
|
121
|
+
flex-direction: column;
|
|
122
|
+
padding: 0.625rem;
|
|
123
|
+
width: 3rem;
|
|
124
|
+
height: 3rem;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.burger-menu::before,
|
|
128
|
+
.burger-menu::after,
|
|
129
|
+
.burger-menu span {
|
|
130
|
+
content: '';
|
|
131
|
+
width: 100%;
|
|
132
|
+
height: 0.1rem;
|
|
133
|
+
background-color: currentColor;
|
|
134
|
+
border-radius: 3rem;
|
|
135
|
+
}
|
|
136
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/* The submenu entries to render. This read menu.ts directly, which is this
|
|
3
|
+
* site's navigation content -- the one thing keeping the component out of the
|
|
4
|
+
* brand package. The caller passes the list instead. */
|
|
5
|
+
defineProps<{
|
|
6
|
+
items: {
|
|
7
|
+
icon: unknown
|
|
8
|
+
name: string
|
|
9
|
+
description: string
|
|
10
|
+
link: string
|
|
11
|
+
}[]
|
|
12
|
+
}>()
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<ul class="grid xl:grid-cols-2 gap-3.5 xl:gap-x-8 xl:gap-y-4">
|
|
17
|
+
<li v-for="item in items" :key="item.name">
|
|
18
|
+
<a :href="item.link" class="flex items-center gap-3 group">
|
|
19
|
+
<div class="w-fit p-3 rounded-xl bg-surface-tertiary">
|
|
20
|
+
<component
|
|
21
|
+
class="w-5 h-5 text-default-transparent group-active:text-hovered group-hover:text-hovered transition-colors"
|
|
22
|
+
:is="item.icon"
|
|
23
|
+
/>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="text-sm">
|
|
26
|
+
<h3 class="font-bold text-heading group-active:text-hovered group-hover:text-hovered transition-colors">
|
|
27
|
+
{{ item.name }}
|
|
28
|
+
</h3>
|
|
29
|
+
<p class="text-body-secondary-lighter group-active:text-hovered group-hover:text-hovered transition-colors">
|
|
30
|
+
{{ item.description }}
|
|
31
|
+
</p>
|
|
32
|
+
</div>
|
|
33
|
+
</a>
|
|
34
|
+
</li>
|
|
35
|
+
</ul>
|
|
36
|
+
</template>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import { Carousel, type CarouselConfig, Slide } from "vue3-carousel";
|
|
4
|
+
import "vue3-carousel/carousel.css";
|
|
5
|
+
import { BREAKPOINTS } from "../../helpers/breakpoints.ts";
|
|
6
|
+
import GlowButton from "../common/GlowButton.vue";
|
|
7
|
+
import TypingEffect from "../common/effects/TypingEffect.vue";
|
|
8
|
+
import TechnologyCard from "./technology-card.vue";
|
|
9
|
+
import { carouselGestureLocker } from "../../helpers/carousel-gesture-locker.ts";
|
|
10
|
+
|
|
11
|
+
/* See ArticlePreview.vue: the fields read here, not the generated Strapi
|
|
12
|
+
* `Technology`, which a caller may still pass unchanged. This component
|
|
13
|
+
* renders no images, so it needs no resolver. */
|
|
14
|
+
type TechnologySummary = {
|
|
15
|
+
name: string
|
|
16
|
+
title1: string
|
|
17
|
+
title2: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const props = defineProps<{
|
|
21
|
+
technologies: TechnologySummary[]
|
|
22
|
+
hideSelected?: boolean
|
|
23
|
+
/* Where the consultation button goes. Was `paths.contactUs`, read straight
|
|
24
|
+
* off this site's route table. */
|
|
25
|
+
ctaHref?: string
|
|
26
|
+
/* Technology name to service URL. Plain data rather than a resolver function
|
|
27
|
+
* because every call site mounts this with `client:only`, and Astro can only
|
|
28
|
+
* hand an island props it can serialise. @helpers/service-links.ts holds the
|
|
29
|
+
* site's map; a name that is missing renders an inert button. */
|
|
30
|
+
serviceLinks?: Record<string, string>
|
|
31
|
+
}>()
|
|
32
|
+
|
|
33
|
+
const activeIndex = ref(0)
|
|
34
|
+
const defaultAutoplayValue = 12000
|
|
35
|
+
const autoplay = ref(defaultAutoplayValue)
|
|
36
|
+
const handleMouseOver = (index: number) => {
|
|
37
|
+
activeIndex.value = index
|
|
38
|
+
autoplay.value = 0
|
|
39
|
+
}
|
|
40
|
+
const selectedTechnology = computed(() => {
|
|
41
|
+
return props.technologies[activeIndex.value]
|
|
42
|
+
})
|
|
43
|
+
const handleMouseLeave = () => {
|
|
44
|
+
activeIndex.value = 0
|
|
45
|
+
autoplay.value = defaultAutoplayValue
|
|
46
|
+
}
|
|
47
|
+
const config = computed<Partial<CarouselConfig>>(() => ({
|
|
48
|
+
gap: 8,
|
|
49
|
+
itemsToShow: 'auto',
|
|
50
|
+
autoplay: autoplay.value,
|
|
51
|
+
wrapAround: true,
|
|
52
|
+
height: 220,
|
|
53
|
+
snapAlign: 'center',
|
|
54
|
+
pauseAutoplayOnHover: true,
|
|
55
|
+
transition: 500,
|
|
56
|
+
breakpoints: {
|
|
57
|
+
[BREAKPOINTS.md]: {
|
|
58
|
+
gap: 16,
|
|
59
|
+
},
|
|
60
|
+
[BREAKPOINTS.xl]: {
|
|
61
|
+
enabled: false,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}))
|
|
65
|
+
const carouselLocker = carouselGestureLocker()
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<section :class="`page-container
|
|
70
|
+
${hideSelected ? 'pt-8' : 'flex flex-col pt-4 md:pt-14 pb-20 xl:pb-0 swipe-over'}`">
|
|
71
|
+
<div v-if="!hideSelected" class="flex flex-col items-center text-center">
|
|
72
|
+
<div class="space-y-3">
|
|
73
|
+
<div class="flex justify-center gap-6 font-bold text-xl">
|
|
74
|
+
<div class="flex flex-col xl:flex-row xl:gap-2">
|
|
75
|
+
<span class="text-body-primary">15+</span>
|
|
76
|
+
<span class="text-body-secondary-lighter">Years of experience</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="flex flex-col xl:flex-row xl:gap-2">
|
|
79
|
+
<span class="text-body-primary">4.8</span>
|
|
80
|
+
<span class="text-body-secondary-lighter">Average rating</span>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<TypingEffect
|
|
84
|
+
:text1="selectedTechnology.title1"
|
|
85
|
+
:text2="selectedTechnology.title2"
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<div v-if="!hideSelected" class="self-center pt-10 pb-[4.5rem] md:pt-14 md:pb-16">
|
|
90
|
+
<GlowButton title="Get a free consultation" :href="ctaHref" />
|
|
91
|
+
</div>
|
|
92
|
+
<div v-else class="pt-[7.5rem] pb-20 space-y-7 text-center">
|
|
93
|
+
<h2 class="font-bold text-heading-md text-heading">
|
|
94
|
+
Looking for something specific?
|
|
95
|
+
</h2>
|
|
96
|
+
<p class="text-xl mx-auto max-w-lg text-body-secondary-lighter">
|
|
97
|
+
We offer a variety of services that might be a game-changer for your business.
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="hidden xl:block relative h-80">
|
|
101
|
+
<div
|
|
102
|
+
v-for="(item, index) in technologies"
|
|
103
|
+
:key="index"
|
|
104
|
+
@mouseover="handleMouseOver(index)"
|
|
105
|
+
@mouseleave="handleMouseLeave"
|
|
106
|
+
>
|
|
107
|
+
<TechnologyCard
|
|
108
|
+
:name="item.name"
|
|
109
|
+
:href="serviceLinks?.[item.name]"
|
|
110
|
+
:active="activeIndex === index"
|
|
111
|
+
className="w-72 h-72"
|
|
112
|
+
:index="index"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<div ref="carouselLocker">
|
|
117
|
+
<Carousel
|
|
118
|
+
v-model="activeIndex"
|
|
119
|
+
v-bind="config"
|
|
120
|
+
class="-mx-4 xl:mx-0 xl:hidden"
|
|
121
|
+
>
|
|
122
|
+
<Slide v-for="(item, index) in technologies" :key="index">
|
|
123
|
+
<div class="flex items-center w-[190px] h-[220px]">
|
|
124
|
+
<TechnologyCard
|
|
125
|
+
:name="item.name"
|
|
126
|
+
:href="serviceLinks?.[item.name]"
|
|
127
|
+
:active="activeIndex === index"
|
|
128
|
+
:className="`w-full h-full ${activeIndex === index ? 'max-h-full' : 'max-h-[190px]'}`"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
</Slide>
|
|
132
|
+
</Carousel>
|
|
133
|
+
</div>
|
|
134
|
+
</section>
|
|
135
|
+
</template>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import Button from "../common/Button.vue";
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
active: boolean
|
|
6
|
+
name: string
|
|
7
|
+
/* Where "Explore service" goes. The card used to switch on `name` over this
|
|
8
|
+
* site's six service routes; that map is now @helpers/service-links.ts and
|
|
9
|
+
* the caller passes the result, so the card reaches no route table. An empty
|
|
10
|
+
* string renders the button inert, exactly as the switch's default did. */
|
|
11
|
+
href?: string
|
|
12
|
+
className?: string
|
|
13
|
+
index?: number
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const rotate = [
|
|
17
|
+
'-rotate-4 left-2 top-10',
|
|
18
|
+
'-rotate-1 left-1/4 top-20',
|
|
19
|
+
'rotate-1 left-1/2 top-20',
|
|
20
|
+
'rotate-4 right-2 top-10',
|
|
21
|
+
'-rotate-2 right-2/3 top-3/5',
|
|
22
|
+
'rotate-2 left-2/3 top-3/5',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const translate = [
|
|
26
|
+
'xl:-translate-y-1/3',
|
|
27
|
+
'xl:-translate-y-1/3',
|
|
28
|
+
'xl:-translate-y-1/3',
|
|
29
|
+
'xl:-translate-y-1/3',
|
|
30
|
+
'xl:-translate-y-1/2',
|
|
31
|
+
'xl:-translate-y-1/2',
|
|
32
|
+
]
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div :class="`rounded-3xl card-wrapper cursor-pointer select-none absolute transform transition-transform duration-500
|
|
37
|
+
${rotate[index]} ${active ? `${translate[index]}` : ''} ${className || ''}`">
|
|
38
|
+
<div class="card flex flex-col items-center justify-around">
|
|
39
|
+
<h3 class="max-w-[8rem] text-center text-xl font-bold text-heading text-balance">
|
|
40
|
+
{{ name }}
|
|
41
|
+
</h3>
|
|
42
|
+
<Button as="link" :href="href ?? ''" title="Explore service" variant="tertiary" :class="`${active ? 'block' : 'hidden xl:block xl:opacity-0'}`" />
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<style scoped>
|
|
48
|
+
.card-wrapper {
|
|
49
|
+
background:
|
|
50
|
+
linear-gradient(hsl(from var(--color-technology-gradient-25) h s l / 0.1),
|
|
51
|
+
hsl(from var(--color-technology-gradient-0) h s l / 0.1),
|
|
52
|
+
hsl(from var(--color-technology-gradient-50) h s l / 0.1)) border-box;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.card {
|
|
56
|
+
width: 100%;
|
|
57
|
+
height: 100%;
|
|
58
|
+
border-radius: inherit;
|
|
59
|
+
position: relative;
|
|
60
|
+
background: transparent;
|
|
61
|
+
backdrop-filter: blur(14px);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.card::before {
|
|
65
|
+
content: '';
|
|
66
|
+
position: absolute;
|
|
67
|
+
z-index: -1;
|
|
68
|
+
inset: 0;
|
|
69
|
+
border-radius: inherit;
|
|
70
|
+
border: 1px solid transparent;
|
|
71
|
+
outline: 1px solid transparent;
|
|
72
|
+
-webkit-backface-visibility: hidden;
|
|
73
|
+
transform: translate3d(0, 0, 0);
|
|
74
|
+
background: linear-gradient(hsl(from var(--color-brand-500) h s l / 0.35),
|
|
75
|
+
hsl(from var(--color-brand-400) h s l / 0.675),
|
|
76
|
+
var(--color-brand-500)) border-box;
|
|
77
|
+
mask: linear-gradient(black, black) border-box,
|
|
78
|
+
linear-gradient(black, black) padding-box;
|
|
79
|
+
mask-composite: subtract;
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { onMounted, onUnmounted, ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
export function carouselGestureLocker() {
|
|
4
|
+
const carouselRoot = ref<HTMLElement | null>(null)
|
|
5
|
+
|
|
6
|
+
let viewport: HTMLElement | null = null
|
|
7
|
+
|
|
8
|
+
let startX = 0
|
|
9
|
+
let startY = 0
|
|
10
|
+
let gestureLocked = false
|
|
11
|
+
let isVerticalGesture = false
|
|
12
|
+
|
|
13
|
+
const onTouchStart = (e: TouchEvent) => {
|
|
14
|
+
startX = e.touches[0].clientX
|
|
15
|
+
startY = e.touches[0].clientY
|
|
16
|
+
|
|
17
|
+
gestureLocked = false
|
|
18
|
+
isVerticalGesture = false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const onTouchMove = (e: TouchEvent) => {
|
|
22
|
+
const dx = Math.abs(e.touches[0].clientX - startX)
|
|
23
|
+
const dy = Math.abs(e.touches[0].clientY - startY)
|
|
24
|
+
|
|
25
|
+
if (!gestureLocked) {
|
|
26
|
+
if (dx < 10 && dy < 10) return
|
|
27
|
+
|
|
28
|
+
gestureLocked = true
|
|
29
|
+
isVerticalGesture = dy > dx
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (isVerticalGesture) {
|
|
33
|
+
e.stopImmediatePropagation()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const onTouchEnd = () => {
|
|
38
|
+
gestureLocked = false
|
|
39
|
+
isVerticalGesture = false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
onMounted(() => {
|
|
43
|
+
|
|
44
|
+
viewport =
|
|
45
|
+
carouselRoot.value?.querySelector('.carousel__viewport') ?? null
|
|
46
|
+
|
|
47
|
+
if (!viewport) return
|
|
48
|
+
|
|
49
|
+
viewport.addEventListener('touchstart', onTouchStart, {
|
|
50
|
+
passive: true,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
viewport.addEventListener('touchmove', onTouchMove, {
|
|
54
|
+
passive: false,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
viewport.addEventListener('touchend', onTouchEnd)
|
|
58
|
+
|
|
59
|
+
viewport.addEventListener('touchcancel', onTouchEnd)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
onUnmounted(() => {
|
|
63
|
+
if (!viewport) return
|
|
64
|
+
|
|
65
|
+
viewport.removeEventListener('touchstart', onTouchStart)
|
|
66
|
+
viewport.removeEventListener('touchmove', onTouchMove)
|
|
67
|
+
viewport.removeEventListener('touchend', onTouchEnd)
|
|
68
|
+
viewport.removeEventListener('touchcancel', onTouchEnd)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
return carouselRoot;
|
|
72
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const formattedDate = (locale: string | null, date: Date | null) => {
|
|
2
|
+
if (!locale || !date) return '';
|
|
3
|
+
|
|
4
|
+
const newDate = new Date(date)
|
|
5
|
+
return new Intl.DateTimeFormat(locale, {
|
|
6
|
+
year: 'numeric',
|
|
7
|
+
month: 'long',
|
|
8
|
+
day: 'numeric'
|
|
9
|
+
}).format(newDate)
|
|
10
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codecavepro/brand",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "CODECAVE design system — colour, typography and layout tokens as CSS custom properties and as a typed module.",
|
|
5
5
|
"license": "Unlicense",
|
|
6
6
|
"type": "module",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"gsap": "^3.13.0",
|
|
42
42
|
"isomorphic-dompurify": "^3.22.0",
|
|
43
43
|
"marked": "^18.0.5",
|
|
44
|
-
"vue": "^3.5.22"
|
|
44
|
+
"vue": "^3.5.22",
|
|
45
|
+
"vue3-carousel": "^0.17.0"
|
|
45
46
|
},
|
|
46
47
|
"peerDependenciesMeta": {
|
|
47
48
|
"gsap": {
|
|
@@ -52,6 +53,9 @@
|
|
|
52
53
|
},
|
|
53
54
|
"marked": {
|
|
54
55
|
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"vue3-carousel": {
|
|
58
|
+
"optional": true
|
|
55
59
|
}
|
|
56
60
|
},
|
|
57
61
|
"scripts": {
|