Compare commits

..

4 Commits

13 changed files with 415 additions and 146 deletions

1
.envrc
View File

@ -1 +1,2 @@
export DATABASE_URL=sqlite3:./games.db export DATABASE_URL=sqlite3:./games.db
export VITE_DB_DIR=/dev/

View File

@ -6,6 +6,24 @@ vars:
GREETING: Hello, World! GREETING: Hello, World!
tasks: tasks:
is-clean: git is-clean
integrate:
deps: [is-clean, test]
cmds:
- echo "do something"
test:unit:
cmds:
- vitest run
test:
deps: [test:unit, test:e2e]
preview:
deps: [build]
cmds:
- npm exec vite preview
test:e2e:
deps: [build]
cmds:
- npm exec playwright test
export-db: export-db:
cmds: cmds:
- sqlite3 /home/bggsell/games.db '.mode json' '.once /home/bggsell/db/games.json' 'select * from game' - sqlite3 /home/bggsell/games.db '.mode json' '.once /home/bggsell/db/games.json' 'select * from game'

26
e2e/hide-game.test.js Normal file
View File

@ -0,0 +1,26 @@
import { test, expect } from '@playwright/test';
test('hide game', async ({ page }) => {
await page.goto('/');
let nbr_games = 0;
await expect(async () => {
nbr_games = await page
.locator('.game')
.all()
.then((games) => games.length);
expect(nbr_games).toBeGreaterThan(0);
}).toPass();
await page.getByRole('button', { name: 'visibility' }).first().click();
await expect(async () => {
const one_hidden = await page
.locator('.game')
.all()
.then((games) => games.length);
expect(one_hidden).toEqual(nbr_games - 1);
}).toPass({ timeout: 10_000 });
});

12
e2e/test.js Normal file
View File

@ -0,0 +1,12 @@
import { expect, test } from '@playwright/test';
for (const url of ['/', '/stats/', '/about/']) {
test(url + ' render', async ({ page }) => {
await page.goto(url);
await expect(
page.getByRole('heading', {
name: 'Ottawa board games for sale and trade',
}),
).toBeVisible();
});
}

View File

@ -16,7 +16,7 @@
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.27.1", "@changesets/cli": "^2.27.1",
"@playwright/test": "^1.28.1", "@playwright/test": "^1.41.2",
"@sveltejs/kit": "^2.5.0", "@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "8.56.0", "@types/eslint": "8.56.0",

View File

@ -1,11 +1,12 @@
/** @type {import('@playwright/test').PlaywrightTestConfig} */ /** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = { const config = {
webServer: { webServer: {
command: 'npm run build && npm run preview', command: 'npm exec vite preview',
port: 4173 port: 4173,
}, reuseExistingServer: true,
testDir: 'tests', },
testMatch: /(.+\.)?(test|spec)\.[jt]s/ testDir: 'e2e',
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
}; };
export default config; export default config;

51
src/lib/stores/index.js Normal file
View File

@ -0,0 +1,51 @@
import { readable, writable, derived } from 'svelte/store';
import { dev, browser } from '$app/environment';
import u from '@yanick/updeep-remeda';
const prefix = import.meta.env.VITE_DB_DIR ?? '/db/';
console.log({ prefix });
const _games_store = readable([], (set) => {
if (!browser) return;
fetch(prefix + 'games.json')
.then((doc) => doc.json())
.then((games) =>
games.map((game) => ({
...game,
id: [game.username, game.bgg_id].join('!'),
})),
)
.then(set);
});
export const sellers_store = readable({}, (set) => {
if (!browser) return;
fetch(prefix + 'sellers.json')
.then((doc) => doc.json())
.then((sellers_list) =>
set(
Object.fromEntries(
sellers_list.map((seller) => [seller.username, seller]),
),
),
);
});
let stored = localStorage.getItem('games_hidden');
if (stored) stored = JSON.parse(stored);
const games_hidden_store = writable(stored || {});
games_hidden_store.subscribe((data) =>
localStorage.setItem('games_hidden', JSON.stringify(data)),
);
export const games_store = derived(
[_games_store, games_hidden_store],
([$games, $hidden]) =>
$games.map((game) => ({ ...game, is_hidden: $hidden[game.id] })),
);
games_store.toggle_hidden = (id) => {
games_hidden_store.update(u.updateIn(id, (v) => !v));
};

View File

@ -15,49 +15,9 @@
import { persisted } from 'svelte-persisted-store'; import { persisted } from 'svelte-persisted-store';
import u from '@yanick/updeep-remeda'; import u from '@yanick/updeep-remeda';
import AppTop from './AppTop.svelte'; import AppTop from './AppTop.svelte';
import { games_store, sellers_store } from '$lib/stores/index.js';
const prefix = dev ? '/dev/' : '/db/'; setContext('games', games_store);
setContext('sellers', sellers_store);
const games = readable([], (set) => {
if (!browser) return () => {};
fetch(prefix + 'games.json')
.then((doc) => doc.json())
.then(set);
return () => {};
});
setContext('games', games);
const sellers = readable({}, (set) => {
if (!browser) return () => {};
const sellers_list = fetch(prefix + 'sellers.json')
.then((doc) => doc.json())
.then((sellers_list) =>
set(
Object.fromEntries(
sellers_list.map((seller) => [seller.username, seller]),
),
),
);
return () => {};
});
setContext('sellers', sellers);
const games_hidden = writable(
JSON.parse(localStorage.getItem('games_hidden') || '{}'),
);
games_hidden.toggle = (username, bgg_id) => {
console.log({ username, bgg_id });
games_hidden.update(
u.updateIn([username, bgg_id].join('!'), (v) => !v),
);
};
setContext('games_hidden', games_hidden);
games_hidden.subscribe((data) =>
localStorage.setItem('games_hidden', JSON.stringify(data)),
);
</script> </script>
<style> <style>

View File

@ -1,5 +1,5 @@
<article> <article>
{#await $games} {#if $games.length == 0}
<div class="medium-height middle-align center-align"> <div class="medium-height middle-align center-align">
<div class="center-align"> <div class="center-align">
<progress class="circle"></progress> <progress class="circle"></progress>
@ -7,9 +7,13 @@
<h5>gathering games...</h5> <h5>gathering games...</h5>
</div> </div>
</div> </div>
{:then games} {:else}
<GameList {games} sellers={$sellers} /> <GameList
{/await} games={$games}
sellers={$sellers}
on:toggle_game_visibility={({ detail }) =>
games.toggle_hidden(detail)} />
{/if}
</article> </article>
<script> <script>

114
src/routes/Game.svelte Normal file
View File

@ -0,0 +1,114 @@
<div
transition:slide={{ delay: 250, duration: 300 }}
class:hidden={is_hidden}
class="game">
<div class="grid top-align">
<a
class="s12 m2"
target="_blank"
href={`https://boardgamegeek.com/boardgame/${bgg_id}`}>
<img alt="" loading="lazy" src={thumbnail} height="80" /></a>
<div class="grid s12 m10">
<div class="s8 left-align">
<strong>
<a
class="game-desc"
target="_blank"
href={`https://boardgamegeek.com/boardgame/${bgg_id}`}>
{name}</a>
</strong>
</div>
<div class="s3">
<div>
<a
target="_blank"
href={`https://boardgamegeek.com/collection/user/${username}?trade=1&subtype=boardgame&ff=1`}
>{username}</a>
&nbsp;
<a
target="_blank"
href={`https://boardgamegeek.com/geekmail/compose?touser=${username}`}>
<i>email</i></a>
</div>
<div class="neighbourhood">
{seller?.neighbourhood ?? ''}
</div>
</div>
<div class="s1 right-align">
{price ? '$' + price : ''}
</div>
<div class="notes s8">
{notes}
</div>
<div class="s3 updated-at">
{#if updated_at}
{pretty_date(updated_at)}
{/if}
</div>
<div class="s1">
<div>
<button
class="transparent round"
title="hide game"
on:click={toggle_game_visibility}
>{#if is_hidden}<i>visibility_off</i>{:else}
<i>visibility</i>
{/if}
</button>
</div>
</div>
</div>
</div>
</div>
<script>
import { createEventDispatcher } from 'svelte';
import { slide, fade } from 'svelte/transition';
export let username = '';
export let name = '';
export let is_hidden = false;
export let bgg_id = '';
export let thumbnail = '';
export let seller = {};
export let price = null;
export let notes = '';
export let updated_at = null;
function pretty_date(date) {
if (!date) return '';
return date.replace(/T.*/, '');
}
const dispatch = createEventDispatcher();
const toggle_game_visibility = () => dispatch('toggle_visibility');
</script>
<style>
a {
color: var(--primary);
text-decoration: underline;
}
.grid {
margin-right: 2em;
font-size: var(--font-size-10);
}
.notes {
margin-top: 1em;
margin-left: 1em;
}
.updated-at {
font-size: smaller;
text-align: left;
}
.updated-at::before {
content: 'last update: ';
}
.neighbourhood {
font-size: smaller;
}
.hidden {
opacity: 60%;
}
</style>

View File

@ -18,106 +18,32 @@
.toLowerCase() .toLowerCase()
.includes(target); .includes(target);
}) as game} }) as game}
{#if show_hidden || !$games_hidden[[game.username, game.bgg_id].join('!')]} {#if show_hidden || !game.is_hidden}
<div <Game
transition:slide={{ delay: 250, duration: 300 }} on:toggle_visibility={() => toggle_visibility(game.id)}
class:hidden={$games_hidden[ {...game}
[game.username, game.bgg_id].join('!') seller={sellers[game.username]} />
]}> <div class="medium-divider"></div>
<div class="grid top-align">
<a
class="s12 m2"
target="_blank"
href={`https://boardgamegeek.com/boardgame/${game.bgg_id}`}>
<img
loading="lazy"
src={game.thumbnail}
height="80" /></a>
<div class="grid s12 m10">
<div class="s8 left-align">
<strong>
<a
class="game-desc"
target="_blank"
href={`https://boardgamegeek.com/boardgame/${game.bgg_id}`}>
{game.name}</a>
</strong>
</div>
<div class="s3">
<div>
<a
target="_blank"
href={`https://boardgamegeek.com/collection/user/${game.username}?trade=1&subtype=boardgame&ff=1`}
>{game.username}</a>
&nbsp;
<a
target="_blank"
href={`https://boardgamegeek.com/geekmail/compose?touser=${game.username}`}>
<i>email</i></a>
</div>
<div class="neighbourhood">
{sellers[game.username]?.neighbourhood ?? ''}
</div>
</div>
<div class="s1 right-align">
{game.price ? '$' + game.price : ''}
</div>
<div class="notes s8">
{game.notes}
</div>
<div class="s3 updated-at">
{#if game.updated_at}
{pretty_date(game.updated_at)}
{/if}
</div>
<div class="s1">
<div>
<button
class="transparent round"
title="hide game"
on:click={() =>
toggle_game_visibility(
game.username,
game.bgg_id,
)}
>{#if $games_hidden[[game.username, game.bgg_id].join('!')]}<i
>visibility_off</i
>{:else}
<i>visibility</i>
{/if}
</button>
</div>
</div>
</div>
</div>
<div class="medium-divider"></div>
</div>
{/if} {/if}
{/each} {/each}
</div> </div>
<script> <script>
import { getContext } from 'svelte'; import { getContext, createEventDispatcher } from 'svelte';
import { slide, fade } from 'svelte/transition'; import Game from './Game.svelte';
export let games; export let games = [];
export let sellers = {}; export let sellers = {};
let show_hidden = false; let show_hidden = false;
let search_text = ''; let search_text = '';
const dispatch = createEventDispatcher();
const toggle_visibility = (id) => dispatch('toggle_game_visibility', id);
// add filter // add filter
// add sort (user, game, price) // add sort (user, game, price)
function pretty_date(date) {
if (!date) return '';
return date.replace(/T.*/, '');
}
const games_hidden = getContext('games_hidden');
const toggle_game_visibility = games_hidden.toggle;
</script> </script>
<style> <style>

View File

@ -0,0 +1,162 @@
<div class="options">
<label class="switch icon">
<input type="checkbox" bind:checked={show_hidden} />
<span> show hidden </span>
</label>
<div class="field label prefix border">
<i>search</i>
<input type="text" bind:value={search_text} />
<label>Search</label>
</div>
</div>
<div class="games">
{#each games.filter((game) => {
if (search_text.length < 3) return true;
const target = search_text.toLowerCase();
return game.name.toLowerCase().includes(target) || game.username
.toLowerCase()
.includes(target);
}) as game}
<<<<<<< HEAD
{#if show_hidden || !game.is_hidden}
<div
transition:slide={{ delay: 250, duration: 300 }}
class:hidden={game.is_hidden}>
<div class="grid top-align">
<a
class="s12 m2"
target="_blank"
href={`https://boardgamegeek.com/boardgame/${game.bgg_id}`}>
<img
loading="lazy"
src={game.thumbnail}
height="80" /></a>
<div class="grid s12 m10">
<div class="s8 left-align">
<strong>
<a
class="game-desc"
target="_blank"
href={`https://boardgamegeek.com/boardgame/${game.bgg_id}`}>
{game.name}</a>
</strong>
</div>
<div class="s3">
<div>
<a
target="_blank"
href={`https://boardgamegeek.com/collection/user/${game.username}?trade=1&subtype=boardgame&ff=1`}
>{game.username}</a>
&nbsp;
<a
target="_blank"
href={`https://boardgamegeek.com/geekmail/compose?touser=${game.username}`}>
<i>email</i></a>
</div>
<div class="neighbourhood">
{sellers[game.username]?.neighbourhood ?? ''}
</div>
</div>
<div class="s1 right-align">
{game.price ? '$' + game.price : ''}
</div>
<div class="notes s8">
{game.notes}
</div>
<div class="s3 updated-at">
{#if game.updated_at}
{pretty_date(game.updated_at)}
{/if}
</div>
<div class="s1">
<div>
<button
class="transparent round"
title="hide game"
on:click={() => toggle_visibility(game.id)}
>{#if game.is_hidden}<i>visibility_off</i
>{:else}
<i>visibility</i>
{/if}
</button>
</div>
</div>
</div>
</div>
<div class="medium-divider"></div>
</div>
=======
{#if show_hidden || !$games_hidden[[game.username, game.bgg_id].join('!')]}
<Game />
<div class="medium-divider"></div>
>>>>>>> 09f4064 (wip)
{/if}
{/each}
</div>
<script>
import { getContext, createEventDispatcher } from 'svelte';
import { slide, fade } from 'svelte/transition';
export let games = [];
export let sellers = {};
let show_hidden = false;
let search_text = '';
const dispatch = createEventDispatcher();
const toggle_visibility = (id) => dispatch('toggle_game_visibility', id);
// add filter
// add sort (user, game, price)
function pretty_date(date) {
if (!date) return '';
return date.replace(/T.*/, '');
}
</script>
<style>
.games {
margin-top: 3em;
}
a {
color: var(--primary);
text-decoration: underline;
}
.grid {
margin-right: 2em;
font-size: var(--font-size-10);
}
.notes {
margin-top: 1em;
margin-left: 1em;
}
.updated-at {
font-size: smaller;
text-align: left;
}
.updated-at::before {
content: 'last update: ';
}
.neighbourhood {
font-size: smaller;
}
.hidden {
opacity: 60%;
}
.switch span {
font-size: var(--font-size-10);
padding-left: 1em;
}
.options {
display: flex;
flex-direction: row-reverse;
}
.options > div {
margin-right: 1em;
}
</style>

View File

@ -1,6 +0,0 @@
import { expect, test } from '@playwright/test';
test('index page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible();
});