Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/renderer/src/components/Library/ClipCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,11 @@ function handleCardClick(event: MouseEvent) {
</button>
</div>

<ClipPublishedBadge :published="clip.published" />
<ClipPublishedBadge
:published="clip.published"
:views="clip.publisherViews"
:last-viewed-at="clip.publisherLastViewedAt"
/>

<!--
The top left corner, as one column rather than two badges each claiming
Expand Down Expand Up @@ -443,8 +447,6 @@ function handleCardClick(event: MouseEvent) {
:duration-sec="clip.durationSec"
:game="gameDisplayName"
:recorded-at="clip.recordedAt ?? clip.fileModifiedAt ?? clip.createdAt"
:views="clip.published ? clip.publisherViews : null"
:last-viewed-at="clip.publisherLastViewedAt"
/>

<ClipTags :clip="clip" @updated="emit('updated', $event)" />
Expand Down
22 changes: 1 addition & 21 deletions src/renderer/src/components/Library/ClipMetadata.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { ref, computed } from 'vue';
import { useConfiguration } from '@renderer/composables/app/useConfiguration';
import { formatRelativeTime, formatExactDate } from '@renderer/helpers/dateFormat';
import { useFormat } from '@renderer/composables/ui/useFormat';
import { viewCountLabel } from '@renderer/utils/viewCount';

/**
* When the clip was recorded, which is the file's own date.
Expand All @@ -30,12 +29,6 @@ interface Props {
*/
durationSec?: number | null;
recordedAt?: string;
/**
* How often the published copy has been opened. Null for a clip that is not
* published or has not been counted; `viewCountLabel` decides what is said.
*/
views?: number | null;
lastViewedAt?: string | null;
}

const props = defineProps<Props>();
Expand All @@ -54,11 +47,6 @@ const length = computed(() => {
return hours ? `${hours}:${two(mins)}:${two(secs)}` : `${mins}:${two(secs)}`;
});

const viewsLabel = computed(() => viewCountLabel(props.views));
const viewsHint = computed(() =>
props.lastViewedAt ? `Last opened ${formatRelativeTime(props.lastViewedAt)}` : undefined,
);

const displayDate = computed(() => {
// No date is no date. Falling back to now would put a confident, wrong
// "just now" on a clip whose age is simply not known.
Expand All @@ -72,8 +60,7 @@ const displayDate = computed(() => {

<template>
<!--
One line: how long, which game, how long ago, how big, and for a
published clip how often it has been opened.
One line: how long, which game, how long ago, how big.

It was the duration and the file size on the left with the date pushed to
the right edge, so each card carried a two-column layout of its own and a
Expand Down Expand Up @@ -101,13 +88,6 @@ const displayDate = computed(() => {
</time>
<span v-if="displayDate && sizeBytes" aria-hidden="true" class="shrink-0">路</span>
<span v-if="sizeBytes" class="shrink-0 tabular-nums">{{ formatBytes(sizeBytes) }}</span>
<span v-if="viewsLabel && (length || game || displayDate || sizeBytes)" aria-hidden="true" class="shrink-0">路</span>
<span
v-if="viewsLabel"
class="shrink-0 tabular-nums"
data-testid="clip-view-count"
:title="viewsHint"
>{{ viewsLabel }}</span>
</div>
</template>

36 changes: 33 additions & 3 deletions src/renderer/src/components/Library/ClipPublishedBadge.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Icon } from '@iconify/vue';
import BaseChip from '@renderer/components/Base/BaseChip.vue';
import { formatRelativeTime } from '@renderer/helpers/dateFormat';
import { viewCountFigure, viewCountLabel } from '@renderer/utils/viewCount';

interface Props {
published?: boolean;
/** How often the published copy has been opened. See `viewCountFigure`. */
views?: number | null;
lastViewedAt?: string | null;
}

defineProps<Props>();
const props = defineProps<Props>();

const figure = computed(() => (props.published ? viewCountFigure(props.views) : ''));
const label = computed(() => viewCountLabel(props.views));
const hint = computed(() =>
props.lastViewedAt
? `${label.value}, last opened ${formatRelativeTime(props.lastViewedAt)}`
: label.value,
);
</script>

<template>
Expand All @@ -15,11 +31,25 @@ defineProps<Props>();
button that was in that corner. The tools are all at the bottom right now,
so there is nothing to dodge, and a badge that moves when you point at the
card is the layout-shift rule broken by animation.

The view count is a chip beside it rather than a word on the metadata line
under the title: a count only exists for a published clip, so it belongs
with the thing that says the clip is published, and the line under the
title was one fact too long with it.
-->
<div v-if="published" class="absolute top-2 right-2 z-10">
<div v-if="published" class="absolute top-2 right-2 z-10 flex items-center gap-1">
<BaseChip>
Published
</BaseChip>
<BaseChip
v-if="figure"
numeric
data-testid="clip-view-count"
:title="hint"
:aria-label="label"
>
<Icon icon="material-symbols:visibility-outline" class="size-3 shrink-0 block" aria-hidden="true" />
{{ figure }}
</BaseChip>
</div>
</template>

15 changes: 11 additions & 4 deletions src/renderer/src/utils/viewCount.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { pluralize } from './pluralize';

/**
* What a library card says about how often a published clip has been watched.
* How often a published clip has been watched, as the figure on its card's
* chip beside Published: `37`, `1,234`. Empty means the chip is not drawn.
*
* **Null says nothing.** It means nobody has counted: a publisher older than
* the counter, or no sync yet. A number there would be invented.
Expand All @@ -13,8 +14,14 @@ import { pluralize } from './pluralize';
*
* Grouped digits because a clip that went round a Discord can reach four.
*/
export function viewCountLabel(views: number | null | undefined): string {
export function viewCountFigure(views: number | null | undefined): string {
if (views == null || !Number.isFinite(views) || views <= 0) return '';
const whole = Math.floor(views);
return `${whole.toLocaleString('en-US')} ${pluralize(whole, 'view')}`;
return Math.floor(views).toLocaleString('en-US');
}

/** The same count in words, for a screen reader: the chip itself is an eye and a number. */
export function viewCountLabel(views: number | null | undefined): string {
const figure = viewCountFigure(views);
if (!figure) return '';
return `${figure} ${pluralize(Math.floor(views as number), 'view')}`;
}
15 changes: 14 additions & 1 deletion tests/unit/renderer/viewCount.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { viewCountLabel } from '../../../src/renderer/src/utils/viewCount';
import { viewCountFigure, viewCountLabel } from '../../../src/renderer/src/utils/viewCount';

/**
* The view count on a library card.
Expand Down Expand Up @@ -31,3 +31,16 @@ describe('viewCountLabel', () => {
expect(viewCountLabel(1234)).toBe('1,234 views');
});
});

describe('viewCountFigure', () => {
it('is empty wherever the label is, so the chip and its words agree', () => {
for (const views of [null, undefined, 0, -3, Number.NaN]) {
expect(viewCountFigure(views)).toBe('');
}
});

it('is the grouped number alone', () => {
expect(viewCountFigure(1)).toBe('1');
expect(viewCountFigure(1234)).toBe('1,234');
});
});
Loading