-
-
Notifications
You must be signed in to change notification settings - Fork 425
feat: add brand page #2197
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add brand page #2197
Changes from 7 commits
9fde292
eeef5e5
fb287bf
c58e7e7
c956570
b4b5630
32900dc
284f70a
35f9f07
3639ee2
f22cec2
ba79696
33410bf
508ecca
9edd0ee
e5ab1bb
8c71173
8cf0210
ab9d274
a848281
22c3bb1
7f07fcb
f4c6916
78df9f3
d1cf5be
d63a73b
7abe2cd
07e9928
a97cf16
c3671ab
7c04e99
540ed93
7d1025a
1e0788f
dc799b9
7dbca4b
b015c8e
b01a764
15d62bd
bba0662
9b9f5f7
6ea2928
cb58641
decb485
4c65b17
8dbb9da
3bff857
fcd3211
4add8d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| <script setup lang="ts"> | ||
| const { accentColors, selectedAccentColor } = useAccentColor() | ||
| const { convert: _convert, download: downloadBlob } = useSvgToPng() | ||
|
|
||
| const customAccent = ref<string | null>(null) | ||
| const customBgDark = ref(true) | ||
| const customLogoRef = useTemplateRef('customLogoRef') | ||
|
|
||
| const activeAccentId = computed(() => customAccent.value ?? selectedAccentColor.value ?? 'sky') | ||
| const activeAccentColor = computed(() => { | ||
| const match = accentColors.value.find(c => c.id === activeAccentId.value) | ||
| const fallback = accentColors.value[0]?.value ?? 'oklch(0.787 0.128 230.318)' | ||
| return match?.value ?? fallback | ||
| }) | ||
|
|
||
| function getCustomSvgString(): string { | ||
| const el = customLogoRef.value?.$el as SVGElement | undefined | ||
| if (!el) return '' | ||
| const clone = el.cloneNode(true) as SVGElement | ||
| clone.querySelectorAll('[fill="currentColor"]').forEach(path => { | ||
| ;(path as SVGElement).setAttribute('fill', customBgDark.value ? '#fafafa' : '#0a0a0a') | ||
| }) | ||
| clone.querySelectorAll('[fill="var(--accent)"]').forEach(path => { | ||
| const style = getComputedStyle(path as SVGElement) | ||
| ;(path as SVGElement).setAttribute('fill', style.fill || activeAccentColor.value) | ||
| }) | ||
| clone.removeAttribute('aria-hidden') | ||
| clone.removeAttribute('class') | ||
| return new XMLSerializer().serializeToString(clone) | ||
| } | ||
|
|
||
| function downloadCustomSvg() { | ||
| const svg = getCustomSvgString() | ||
| if (!svg) return | ||
| const blob = new Blob([svg], { type: 'image/svg+xml' }) | ||
| const url = URL.createObjectURL(blob) | ||
| const a = document.createElement('a') | ||
| a.href = url | ||
| a.download = `npmx-logo-${activeAccentId.value}.svg` | ||
| a.click() | ||
| URL.revokeObjectURL(url) | ||
| } | ||
|
|
||
| const pngLoading = ref(false) | ||
|
|
||
| async function downloadCustomPng() { | ||
| const svg = getCustomSvgString() | ||
| if (!svg) return | ||
| pngLoading.value = true | ||
| try { | ||
| await document.fonts.ready | ||
| const blob = new Blob([svg], { type: 'image/svg+xml' }) | ||
| const url = URL.createObjectURL(blob) | ||
|
|
||
| const img = new Image() | ||
| const loaded = new Promise<void>((resolve, reject) => { | ||
| img.onload = () => resolve() | ||
| img.onerror = () => reject(new Error('Failed to load custom SVG')) | ||
| }) | ||
| img.src = url | ||
| await loaded | ||
|
|
||
| const scale = 2 | ||
| const canvas = document.createElement('canvas') | ||
| canvas.width = 602 * scale | ||
| canvas.height = 170 * scale | ||
| const ctx = canvas.getContext('2d')! | ||
|
ghostdevv marked this conversation as resolved.
|
||
| ctx.fillStyle = customBgDark.value ? '#0a0a0a' : '#ffffff' | ||
| ctx.fillRect(0, 0, canvas.width, canvas.height) | ||
| ctx.scale(scale, scale) | ||
| ctx.drawImage(img, 0, 0, 602, 170) | ||
|
|
||
| canvas.toBlob(pngBlob => { | ||
| if (pngBlob) downloadBlob(pngBlob, `npmx-logo-${activeAccentId.value}.png`) | ||
| URL.revokeObjectURL(url) | ||
| }, 'image/png') | ||
| } finally { | ||
| pngLoading.value = false | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
| </script> | ||
|
|
||
| <template> | ||
| <section aria-labelledby="brand-customize-heading"> | ||
| <h2 id="brand-customize-heading" class="text-lg text-fg uppercase tracking-wider mb-4"> | ||
| {{ $t('brand.customize.title') }} | ||
| </h2> | ||
| <p class="text-fg-muted leading-relaxed mb-8"> | ||
| {{ $t('brand.customize.description') }} | ||
| </p> | ||
|
|
||
| <div class="border border-border rounded-lg overflow-hidden"> | ||
| <!-- Live preview --> | ||
| <div | ||
| class="flex items-center justify-center p-10 sm:p-16 transition-colors duration-300 motion-reduce:transition-none" | ||
| :class="customBgDark ? 'bg-[#0a0a0a]' : 'bg-white'" | ||
| > | ||
| <AppLogo | ||
| ref="customLogoRef" | ||
| class="h-10 sm:h-14 w-auto max-w-full transition-colors duration-300 motion-reduce:transition-none" | ||
| :class="customBgDark ? 'text-[#fafafa]' : 'text-[#0a0a0a]'" | ||
| :style="{ '--accent': activeAccentColor }" | ||
| /> | ||
| </div> | ||
|
|
||
| <!-- Controls --> | ||
| <div | ||
| class="border-t border-border p-4 sm:p-6 flex flex-col sm:flex-row sm:items-center gap-4" | ||
| > | ||
| <!-- Accent color picker --> | ||
| <fieldset class="flex items-center gap-3 flex-1 border-none p-0 m-0"> | ||
| <legend class="sr-only">{{ $t('brand.customize.accent_label') }}</legend> | ||
| <span class="text-xs font-mono text-fg-muted shrink-0">{{ | ||
| $t('brand.customize.accent_label') | ||
| }}</span> | ||
| <div class="flex items-center gap-1.5" role="radiogroup"> | ||
| <button | ||
| v-for="color in accentColors" | ||
| :key="color.id" | ||
| type="button" | ||
| role="radio" | ||
| :aria-checked="activeAccentId === color.id" | ||
| :aria-label="color.label" | ||
| class="w-6 h-6 rounded-full border-2 cursor-pointer transition-all duration-150 focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg motion-reduce:transition-none" | ||
| :class=" | ||
| activeAccentId === color.id | ||
| ? 'border-fg scale-110' | ||
| : 'border-transparent hover:border-border-hover' | ||
| " | ||
| :style="{ backgroundColor: color.value }" | ||
| @click="customAccent = color.id" | ||
| /> | ||
| </div> | ||
| </fieldset> | ||
|
|
||
| <!-- Background toggle --> | ||
| <div class="flex items-center gap-3"> | ||
| <span class="text-xs font-mono text-fg-muted">{{ $t('brand.customize.bg_label') }}</span> | ||
| <div | ||
| class="flex items-center border border-border rounded-md overflow-hidden" | ||
| role="radiogroup" | ||
| > | ||
| <button | ||
| type="button" | ||
| role="radio" | ||
| :aria-checked="customBgDark" | ||
| :aria-label="$t('brand.logos.on_dark')" | ||
| class="px-2.5 py-1 text-xs font-mono cursor-pointer border-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent motion-reduce:transition-none" | ||
| :class=" | ||
| customBgDark ? 'bg-bg-muted text-fg' : 'bg-transparent text-fg-muted hover:text-fg' | ||
| " | ||
| @click="customBgDark = true" | ||
| > | ||
| {{ $t('brand.logos.on_dark') }} | ||
| </button> | ||
| <button | ||
| type="button" | ||
| role="radio" | ||
| :aria-checked="!customBgDark" | ||
| :aria-label="$t('brand.logos.on_light')" | ||
| class="px-2.5 py-1 text-xs font-mono cursor-pointer border-none border-is border-is-border transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent motion-reduce:transition-none" | ||
| :class=" | ||
| !customBgDark ? 'bg-bg-muted text-fg' : 'bg-transparent text-fg-muted hover:text-fg' | ||
| " | ||
| @click="customBgDark = false" | ||
| > | ||
| {{ $t('brand.logos.on_light') }} | ||
| </button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- Download buttons --> | ||
| <div class="flex items-center gap-2"> | ||
| <ButtonBase | ||
| size="sm" | ||
| classicon="i-lucide:download" | ||
| :aria-label="$t('brand.customize.download_svg_aria')" | ||
| @click="downloadCustomSvg" | ||
| > | ||
| {{ $t('brand.logos.download_svg') }} | ||
| </ButtonBase> | ||
| <ButtonBase | ||
| size="sm" | ||
| classicon="i-lucide:download" | ||
| :aria-label="$t('brand.customize.download_png_aria')" | ||
| :disabled="pngLoading" | ||
| @click="downloadCustomPng" | ||
| > | ||
| {{ $t('brand.logos.download_png') }} | ||
| </ButtonBase> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </section> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| <script setup lang="ts"> | ||
| const show = shallowRef(false) | ||
| const x = shallowRef(0) | ||
| const y = shallowRef(0) | ||
| const menuRef = useTemplateRef('menuRef') | ||
|
|
||
| function onContextMenu(e: MouseEvent) { | ||
| e.preventDefault() | ||
| x.value = e.clientX | ||
| y.value = e.clientY | ||
| show.value = true | ||
| nextTick(() => { | ||
| menuRef.value?.focus() | ||
| clampPosition() | ||
| }) | ||
| } | ||
|
|
||
| function clampPosition() { | ||
| const el = menuRef.value | ||
| if (!el) return | ||
| const rect = el.getBoundingClientRect() | ||
| if (rect.right > window.innerWidth) { | ||
| x.value -= rect.right - window.innerWidth + 8 | ||
| } | ||
| if (rect.bottom > window.innerHeight) { | ||
| y.value -= rect.bottom - window.innerHeight + 8 | ||
| } | ||
| } | ||
|
|
||
| function close() { | ||
| show.value = false | ||
| } | ||
|
|
||
| const { copy, copied } = useClipboard({ copiedDuring: 2000 }) | ||
|
|
||
| async function copySvg() { | ||
| try { | ||
| const res = await fetch('/logo.svg') | ||
| const svg = await res.text() | ||
| await copy(svg) | ||
| } finally { | ||
| close() | ||
| } | ||
| } | ||
|
Comment on lines
+36
to
+67
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this won't work on Safari but I'm not going to block this right now - we can fix this at the same time we fix #2151
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added a fix for this here. 😄 |
||
|
|
||
| function goToBrand() { | ||
| close() | ||
| navigateTo({ name: 'brand' }) | ||
| } | ||
|
|
||
| onClickOutside(menuRef, close) | ||
|
|
||
| onKeyStroke('Escape', () => { | ||
| if (show.value) close() | ||
| }) | ||
| </script> | ||
|
|
||
| <template> | ||
| <div class="contents" @contextmenu="onContextMenu"> | ||
| <slot /> | ||
|
|
||
| <Teleport to="body"> | ||
| <Transition | ||
| enter-active-class="transition duration-100 ease-out" | ||
| enter-from-class="opacity-0 scale-95" | ||
| enter-to-class="opacity-100 scale-100" | ||
| leave-active-class="transition duration-75 ease-in" | ||
| leave-from-class="opacity-100 scale-100" | ||
| leave-to-class="opacity-0 scale-95" | ||
| > | ||
| <div | ||
| v-if="show" | ||
| ref="menuRef" | ||
| role="menu" | ||
| tabindex="-1" | ||
| class="fixed z-[999] min-w-48 bg-bg-elevated border border-border rounded-lg shadow-lg py-1 origin-top-left focus:outline-none motion-reduce:transition-none" | ||
| :style="{ left: `${x}px`, top: `${y}px` }" | ||
| @keydown.escape="close" | ||
| > | ||
| <ButtonBase | ||
| role="menuitem" | ||
| class="w-full text-start gap-x-3 border-none" | ||
| :classicon="copied ? 'i-lucide:check text-badge-green' : 'i-lucide:copy'" | ||
| @click="copySvg" | ||
| > | ||
| {{ copied ? $t('brand.colors.copied') : $t('logo_menu.copy_svg') }} | ||
| </ButtonBase> | ||
| <ButtonBase | ||
| role="menuitem" | ||
| class="w-full text-start gap-x-3 border-none" | ||
| classicon="i-lucide:palette" | ||
| @click="goToBrand" | ||
| > | ||
| {{ $t('logo_menu.browse_brand') }} | ||
| </ButtonBase> | ||
| </div> | ||
| </Transition> | ||
| </Teleport> | ||
| </div> | ||
| </template> | ||
Uh oh!
There was an error while loading. Please reload this page.