Skip to content

Commit 065ad9b

Browse files
authored
feat: allow showing dates in relative time (#149)
1 parent a7d1498 commit 065ad9b

9 files changed

Lines changed: 441 additions & 19 deletions

File tree

app/components/AppHeader.vue

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ withDefaults(
2626
<span v-else class="w-1" />
2727

2828
<ul class="flex items-center gap-4 sm:gap-6 list-none m-0 p-0">
29-
<li class="flex">
29+
<li class="flex items-center">
3030
<NuxtLink
3131
to="/search"
3232
class="link-subtle font-mono text-sm inline-flex items-center gap-2"
@@ -41,17 +41,23 @@ withDefaults(
4141
</kbd>
4242
</NuxtLink>
4343
</li>
44-
<li v-if="showConnector" class="flex">
44+
<li class="flex items-center">
45+
<ClientOnly>
46+
<SettingsMenu />
47+
</ClientOnly>
48+
</li>
49+
<li v-if="showConnector" class="flex items-center">
4550
<ConnectorStatus />
4651
</li>
47-
<li v-else class="flex">
52+
<li v-else class="flex items-center">
4853
<a
4954
href="https://github.com/npmx-dev/npmx.dev"
5055
rel="noopener noreferrer"
5156
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
57+
aria-label="GitHub"
5258
>
53-
<span class="i-carbon-logo-github w-4 h-4" />
54-
<span class="hidden sm:inline">github</span>
59+
<span class="i-carbon-logo-github w-4 h-4" aria-hidden="true" />
60+
<span class="hidden sm:inline" aria-hidden="true">github</span>
5561
</a>
5662
</li>
5763
</ul>

app/components/DateTime.vue

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<script setup lang="ts">
2+
/**
3+
* DateTime component that wraps NuxtTime with settings-aware relative date support.
4+
* Uses the global settings to determine whether to show relative or absolute dates.
5+
*
6+
* Note: When relativeDates setting is enabled, the component switches between
7+
* relative and absolute display based on user preference. The title attribute
8+
* always shows the full date for accessibility.
9+
*/
10+
const props = withDefaults(
11+
defineProps<{
12+
/** The datetime value (ISO string or Date) */
13+
datetime: string | Date
14+
/** Override title (defaults to datetime) */
15+
title?: string
16+
/** Date style for absolute display */
17+
dateStyle?: 'full' | 'long' | 'medium' | 'short'
18+
/** Individual date parts for absolute display (alternative to dateStyle) */
19+
year?: 'numeric' | '2-digit'
20+
month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow'
21+
day?: 'numeric' | '2-digit'
22+
}>(),
23+
{
24+
title: undefined,
25+
dateStyle: undefined,
26+
year: undefined,
27+
month: undefined,
28+
day: undefined,
29+
},
30+
)
31+
32+
const relativeDates = useRelativeDates()
33+
34+
// Compute the title - always show full date for accessibility
35+
const titleValue = computed(() => {
36+
if (props.title) return props.title
37+
if (typeof props.datetime === 'string') return props.datetime
38+
return props.datetime.toISOString()
39+
})
40+
</script>
41+
42+
<template>
43+
<ClientOnly>
44+
<NuxtTime v-if="relativeDates" :datetime="datetime" :title="titleValue" relative />
45+
<NuxtTime
46+
v-else
47+
:datetime="datetime"
48+
:title="titleValue"
49+
:date-style="dateStyle"
50+
:year="year"
51+
:month="month"
52+
:day="day"
53+
/>
54+
<template #fallback>
55+
<NuxtTime
56+
:datetime="datetime"
57+
:title="titleValue"
58+
:date-style="dateStyle"
59+
:year="year"
60+
:month="month"
61+
:day="day"
62+
/>
63+
</template>
64+
</ClientOnly>
65+
</template>

app/components/PackageCard.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const emit = defineEmits<{
7676
<div v-if="result.package.date" class="flex items-center gap-1.5">
7777
<dt class="sr-only">Updated</dt>
7878
<dd>
79-
<NuxtTime
79+
<DateTime
8080
:datetime="result.package.date"
8181
year="numeric"
8282
month="short"

app/components/PackageVersions.vue

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -343,10 +343,9 @@ function getTagVersions(tag: string): VersionDisplay[] {
343343
{{ row.primaryVersion.version }}
344344
</NuxtLink>
345345
<div class="flex items-center gap-2 shrink-0">
346-
<NuxtTime
346+
<DateTime
347347
v-if="row.primaryVersion.time"
348348
:datetime="row.primaryVersion.time"
349-
:title="row.primaryVersion.time"
350349
year="numeric"
351350
month="short"
352351
day="numeric"
@@ -393,10 +392,9 @@ function getTagVersions(tag: string): VersionDisplay[] {
393392
{{ v.version }}
394393
</NuxtLink>
395394
<div class="flex items-center gap-2 shrink-0">
396-
<NuxtTime
395+
<DateTime
397396
v-if="v.time"
398397
:datetime="v.time"
399-
:title="v.time"
400398
class="text-[10px] text-fg-subtle"
401399
year="numeric"
402400
month="short"
@@ -475,10 +473,9 @@ function getTagVersions(tag: string): VersionDisplay[] {
475473
{{ row.primaryVersion.version }}
476474
</NuxtLink>
477475
<div class="flex items-center gap-2 shrink-0">
478-
<NuxtTime
476+
<DateTime
479477
v-if="row.primaryVersion.time"
480478
:datetime="row.primaryVersion.time"
481-
:title="row.primaryVersion.time"
482479
class="text-[10px] text-fg-subtle"
483480
year="numeric"
484481
month="short"
@@ -586,10 +583,9 @@ function getTagVersions(tag: string): VersionDisplay[] {
586583
{{ v.version }}
587584
</NuxtLink>
588585
<div class="flex items-center gap-2 shrink-0">
589-
<NuxtTime
586+
<DateTime
590587
v-if="v.time"
591588
:datetime="v.time"
592-
:title="v.time"
593589
class="text-[10px] text-fg-subtle"
594590
year="numeric"
595591
month="short"

app/components/SettingsMenu.vue

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<script setup lang="ts">
2+
import { onKeyStroke, onClickOutside } from '@vueuse/core'
3+
4+
const { settings } = useSettings()
5+
6+
const isOpen = ref(false)
7+
const menuRef = useTemplateRef('menuRef')
8+
const triggerRef = useTemplateRef('triggerRef')
9+
10+
function toggle() {
11+
isOpen.value = !isOpen.value
12+
}
13+
14+
function close() {
15+
isOpen.value = false
16+
}
17+
18+
// Close on click outside
19+
onClickOutside(menuRef, close, { ignore: [triggerRef] })
20+
21+
// Close on Escape
22+
onKeyStroke('Escape', () => {
23+
if (isOpen.value) {
24+
close()
25+
triggerRef.value?.focus()
26+
}
27+
})
28+
29+
// Open with comma key (global shortcut)
30+
onKeyStroke(',', e => {
31+
// Don't trigger if user is typing in an input
32+
const target = e.target as HTMLElement
33+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
34+
return
35+
}
36+
e.preventDefault()
37+
toggle()
38+
})
39+
</script>
40+
41+
<template>
42+
<div class="relative flex items-center">
43+
<button
44+
ref="triggerRef"
45+
type="button"
46+
class="link-subtle font-mono text-sm inline-flex items-center justify-center gap-2"
47+
:aria-expanded="isOpen"
48+
aria-haspopup="menu"
49+
aria-label="Settings"
50+
aria-keyshortcuts=","
51+
@click="toggle"
52+
>
53+
<span class="i-carbon-settings w-4 h-4 sm:hidden" aria-hidden="true" />
54+
<span class="hidden sm:inline">settings</span>
55+
<kbd
56+
class="hidden sm:inline-flex items-center justify-center w-5 h-5 text-xs bg-bg-muted border border-border rounded"
57+
aria-hidden="true"
58+
>
59+
,
60+
</kbd>
61+
</button>
62+
63+
<!-- Settings popover -->
64+
<Transition
65+
enter-active-class="transition-opacity transition-transform duration-100 ease-out motion-reduce:transition-none"
66+
enter-from-class="opacity-0 scale-95"
67+
enter-to-class="opacity-100 scale-100"
68+
leave-active-class="transition-opacity transition-transform duration-75 ease-in motion-reduce:transition-none"
69+
leave-from-class="opacity-100 scale-100"
70+
leave-to-class="opacity-0 scale-95"
71+
>
72+
<div
73+
v-if="isOpen"
74+
ref="menuRef"
75+
role="menu"
76+
class="absolute right-0 top-full mt-2 w-64 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 overflow-hidden"
77+
>
78+
<div class="px-3 py-2 border-b border-border">
79+
<h2 class="text-xs text-fg-subtle uppercase tracking-wider">Settings</h2>
80+
</div>
81+
82+
<div class="p-2 space-y-1">
83+
<!-- Relative dates toggle -->
84+
<div
85+
class="flex items-center justify-between gap-3 px-2 py-2 rounded-md hover:bg-bg-muted transition-[background-color] duration-150 cursor-pointer"
86+
@click="settings.relativeDates = !settings.relativeDates"
87+
>
88+
<label
89+
:id="`settings-relative-dates-label`"
90+
class="text-sm text-fg cursor-pointer select-none"
91+
>
92+
Relative dates
93+
</label>
94+
<button
95+
type="button"
96+
role="switch"
97+
:aria-checked="settings.relativeDates"
98+
aria-labelledby="settings-relative-dates-label"
99+
class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-[background-color] duration-200 ease-in-out motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
100+
:class="settings.relativeDates ? 'bg-fg' : 'bg-bg-subtle'"
101+
@click.stop="settings.relativeDates = !settings.relativeDates"
102+
>
103+
<span
104+
aria-hidden="true"
105+
class="pointer-events-none inline-block h-4 w-4 rounded-full shadow-sm ring-0 transition-transform duration-200 ease-in-out motion-reduce:transition-none"
106+
:class="
107+
settings.relativeDates ? 'translate-x-4 bg-bg' : 'translate-x-0 bg-fg-muted'
108+
"
109+
/>
110+
</button>
111+
</div>
112+
</div>
113+
</div>
114+
</Transition>
115+
</div>
116+
</template>

app/composables/useSettings.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { RemovableRef } from '@vueuse/core'
2+
import { useLocalStorage } from '@vueuse/core'
3+
4+
/**
5+
* Application settings stored in localStorage
6+
*/
7+
export interface AppSettings {
8+
/** Display dates as relative (e.g., "3 days ago") instead of absolute */
9+
relativeDates: boolean
10+
}
11+
12+
const DEFAULT_SETTINGS: AppSettings = {
13+
relativeDates: false,
14+
}
15+
16+
const STORAGE_KEY = 'npmx-settings'
17+
18+
// Shared settings instance (singleton per app)
19+
let settingsRef: RemovableRef<AppSettings> | null = null
20+
21+
/**
22+
* Composable for managing application settings with localStorage persistence.
23+
* Settings are shared across all components that use this composable.
24+
*/
25+
export function useSettings() {
26+
if (!settingsRef) {
27+
settingsRef = useLocalStorage<AppSettings>(STORAGE_KEY, DEFAULT_SETTINGS, {
28+
mergeDefaults: true,
29+
})
30+
}
31+
32+
return {
33+
settings: settingsRef,
34+
}
35+
}
36+
37+
/**
38+
* Composable for accessing just the relative dates setting.
39+
* Useful for components that only need to read this specific setting.
40+
*/
41+
export function useRelativeDates() {
42+
const { settings } = useSettings()
43+
return computed(() => settings.value.relativeDates)
44+
}

app/pages/[...package].vue

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -577,11 +577,7 @@ defineOgImageComponent('Package', {
577577
<div v-if="pkg.time?.modified" class="space-y-1">
578578
<dt class="text-xs text-fg-subtle uppercase tracking-wider sm:text-right">Updated</dt>
579579
<dd class="font-mono text-sm text-fg sm:text-right">
580-
<NuxtTime
581-
:datetime="pkg.time.modified"
582-
:title="pkg.time.modified"
583-
date-style="medium"
584-
/>
580+
<DateTime :datetime="pkg.time.modified" date-style="medium" />
585581
</dd>
586582
</div>
587583
</dl>

0 commit comments

Comments
 (0)