Skip to content
Open
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
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions svelte/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@crates-io/api-client": "workspace:*"
},
"devDependencies": {
"@chromatic-com/storybook": "4.1.3",
"@eslint/compat": "2.0.0",
Expand Down
91 changes: 91 additions & 0 deletions svelte/src/lib/components/frontpage/CrateLists.stories.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';

import CrateLists from './CrateLists.svelte';

const { Story } = defineMeta({
title: 'Frontpage/CrateLists',
component: CrateLists,
tags: ['autodocs'],
});

function createMockCrate(name: string, version: string) {
return {
id: name,
name,
newest_version: version,
max_version: version,
downloads: 1000000,
created_at: '2020-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
badges: [],
exact_match: false,
links: {
reverse_dependencies: `/api/v1/crates/${name}/reverse_dependencies`,
version_downloads: `/api/v1/crates/${name}/downloads`,
},
num_versions: 10,
trustpub_only: false,
yanked: false,
};
}

function createMockKeyword(id: string, crates_cnt: number) {
return {
id,
keyword: id,
crates_cnt,
created_at: '2020-01-01T00:00:00Z',
};
}

function createMockCategory(id: string, name: string, crates_cnt: number) {
return {
id,
slug: id,
category: name,
description: `${name} related crates`,
crates_cnt,
created_at: '2020-01-01T00:00:00Z',
};
}

const mockSummary = {
num_downloads: 123456789,
num_crates: 150000,
new_crates: [
createMockCrate('new-crate-1', '0.1.0'),
createMockCrate('new-crate-2', '0.2.0'),
createMockCrate('new-crate-3', '0.1.0'),
],
most_downloaded: [
createMockCrate('serde', '1.0.200'),
createMockCrate('tokio', '1.37.0'),
createMockCrate('rand', '0.8.5'),
],
most_recently_downloaded: [
createMockCrate('syn', '2.0.60'),
createMockCrate('quote', '1.0.36'),
createMockCrate('proc-macro2', '1.0.81'),
],
just_updated: [
createMockCrate('updated-crate-1', '2.0.0'),
createMockCrate('updated-crate-2', '1.5.0'),
createMockCrate('updated-crate-3', '3.0.0-beta.1'),
],
popular_keywords: [
createMockKeyword('async', 5000),
createMockKeyword('http', 3500),
createMockKeyword('cli', 2800),
],
popular_categories: [
createMockCategory('command-line-utilities', 'Command-line utilities', 4500),
createMockCategory('web-programming', 'Web programming', 3800),
createMockCategory('development-tools', 'Development tools', 3200),
],
};
</script>

<Story name="Loaded" args={{ summary: mockSummary }} />

<Story name="Loading" />
189 changes: 189 additions & 0 deletions svelte/src/lib/components/frontpage/CrateLists.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<script lang="ts">
import type { operations } from '@crates-io/api-client';

import { resolve } from '$app/paths';

import ListItem from './ListItem.svelte';
import ListItemPlaceholder from './ListItemPlaceholder.svelte';

type Summary = operations['get_summary']['responses']['200']['content']['application/json'];

interface Props {
summary?: Summary;
}

let { summary }: Props = $props();

const numberFormat = new Intl.NumberFormat();
</script>

<div class="lists" data-test-lists>
<section data-test-new-crates>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<h2><a href={`${resolve('/crates')}?sort=new`}>New Crates</a></h2>
<ol class="list" aria-busy={Boolean(summary)}>
{#if !summary}
{#each { length: 10 } as _, i (i)}
<li><ListItemPlaceholder withSubtitle /></li>
{/each}
{:else}
{#each summary.new_crates as crate, index (crate.id)}
<li>
<ListItem
title={crate.name}
subtitle={`v${crate.newest_version}`}
href={resolve('/crates/[crate_id]', { crate_id: crate.id })}
data-test-crate-link={index}
/>
</li>
{/each}
{/if}
</ol>
</section>

<section data-test-most-downloaded>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<h2><a href={`${resolve('/crates')}?sort=downloads`}>Most Downloaded</a></h2>
<ol class="list" aria-busy={Boolean(summary)}>
{#if !summary}
{#each { length: 10 } as _, i (i)}
<li><ListItemPlaceholder /></li>
{/each}
{:else}
{#each summary.most_downloaded as crate, index (crate.id)}
<li>
<ListItem
title={crate.name}
href={resolve('/crates/[crate_id]', { crate_id: crate.id })}
data-test-crate-link={index}
/>
</li>
{/each}
{/if}
</ol>
</section>

<section data-test-just-updated>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<h2><a href={`${resolve('/crates')}?sort=recent-updates`}>Just Updated</a></h2>
<ol class="list" aria-busy={Boolean(summary)}>
{#if !summary}
{#each { length: 10 } as _, i (i)}
<li><ListItemPlaceholder withSubtitle /></li>
{/each}
{:else}
{#each summary.just_updated as crate, index (crate.id)}
<li>
<ListItem
title={crate.name}
subtitle={`v${crate.newest_version}`}
href={resolve('/crates/[crate_id]/[version_num]', {
crate_id: crate.id,
version_num: crate.newest_version,
})}
data-test-crate-link={index}
/>
</li>
{/each}
{/if}
</ol>
</section>

<section data-test-most-recently-downloaded>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<h2><a href={`${resolve('/crates')}?sort=recent-downloads`}>Most Recent Downloads</a></h2>
<ol class="list" aria-busy={Boolean(summary)}>
{#if !summary}
{#each { length: 10 } as _, i (i)}
<li><ListItemPlaceholder /></li>
{/each}
{:else}
{#each summary.most_recently_downloaded as crate, index (crate.id)}
<li>
<ListItem
title={crate.name}
href={resolve('/crates/[crate_id]', { crate_id: crate.id })}
data-test-crate-link={index}
/>
</li>
{/each}
{/if}
</ol>
</section>

<section data-test-keywords>
<h2><a href={resolve('/keywords')}>Popular Keywords</a></h2>
<ul class="list" aria-busy={Boolean(summary)}>
{#if !summary}
{#each { length: 10 } as _, i (i)}
<li><ListItemPlaceholder withSubtitle /></li>
{/each}
{:else}
{#each summary.popular_keywords as keyword (keyword.id)}
<li>
<ListItem
title={keyword.id}
subtitle={`${numberFormat.format(keyword.crates_cnt)} crates`}
href={resolve('/keywords/[keyword_id]', { keyword_id: keyword.id })}
/>
</li>
{/each}
{/if}
</ul>
</section>

<section data-test-categories>
<h2><a href={resolve('/categories')}>Popular Categories</a></h2>
<ul class="list" aria-busy={Boolean(summary)}>
{#if !summary}
{#each { length: 10 } as _, i (i)}
<li><ListItemPlaceholder withSubtitle /></li>
{/each}
{:else}
{#each summary.popular_categories as category (category.id)}
<li>
<ListItem
title={category.category}
subtitle={`${numberFormat.format(category.crates_cnt)} crates`}
href={resolve('/categories/[category_id]', { category_id: category.slug })}
/>
</li>
{/each}
{/if}
</ul>
</section>
Comment on lines +21 to +154
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could simplify this a bit with a snippet, though it doesn't have to be in this PR! :D

</div>

<style>
.lists {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--space-s);
padding: 0 var(--space-s);

@media only screen and (max-width: 750px) {
grid-template-columns: 1fr 1fr;
}

@media only screen and (max-width: 550px) {
grid-template-columns: 1fr;
}

h2 {
font-size: 1.05rem;

a:not(:hover) {
color: var(--main-color);
}
}
}

.list {
list-style: none;
padding: 0;

> * + * {
margin-top: var(--space-2xs);
}
}
</style>
15 changes: 15 additions & 0 deletions svelte/src/lib/components/frontpage/ErrorState.stories.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';

import ErrorState from './ErrorState.svelte';

const { Story } = defineMeta({
title: 'Frontpage/ErrorState',
component: ErrorState,
tags: ['autodocs'],
});
</script>

<Story name="Default" args={{ isLoading: false, onRetry: () => alert('Retry clicked!') }} />

<Story name="Loading" args={{ isLoading: true }} />
38 changes: 38 additions & 0 deletions svelte/src/lib/components/frontpage/ErrorState.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script lang="ts">
import LoadingSpinner from '$lib/components/LoadingSpinner.svelte';

interface Props {
isLoading: boolean;
onRetry?: () => void;
}

let { isLoading, onRetry }: Props = $props();
</script>

<p class="error-message" data-test-error-message>
Unfortunately something went wrong while loading the crates.io summary data. Feel free to try again, or let the
<a href="mailto:[email protected]">crates.io team</a>
know if the problem persists.
</p>

<button type="button" disabled={isLoading} class="try-again-button button" onclick={onRetry} data-test-try-again-button>
Try Again
{#if isLoading}
<LoadingSpinner theme="light" class="spinner" data-test-spinner />
{/if}
</button>

<style>
.error-message {
line-height: 1.5;
}

.try-again-button {
align-self: center;
margin: var(--space-s) 0;

:global(.spinner) {
margin-left: var(--space-2xs);
}
}
</style>
13 changes: 13 additions & 0 deletions svelte/src/lib/components/frontpage/HeroButtons.stories.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import HeroButtons from './HeroButtons.svelte';
const { Story } = defineMeta({
title: 'Frontpage/HeroButtons',
component: HeroButtons,
tags: ['autodocs'],
});
</script>

<Story name="Default" />
Loading
Loading