Website
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
PUBLIC_GITHUB_USERNAME=your_github_username
|
||||
PUBLIC_GITHUB_API_URL="https://api.github.com"
|
55
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: 'main'
|
||||
|
||||
jobs:
|
||||
build_site:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# If you're using pnpm, add this step then change the commands and cache key below to use `pnpm`
|
||||
# - name: Install pnpm
|
||||
# uses: pnpm/action-setup@v3
|
||||
# with:
|
||||
# version: 8
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: build
|
||||
env:
|
||||
BASE_PATH: '/${{ github.event.repository.name }}'
|
||||
run: |
|
||||
npm run build
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: 'build/'
|
||||
|
||||
deploy:
|
||||
needs: build_site
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
steps:
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
831
package-lock.json
generated
@ -18,7 +18,7 @@
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@fontsource/fira-mono": "^5.0.0",
|
||||
"@neoconfetti/svelte": "^2.0.0",
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
@ -36,5 +36,9 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"vite-plugin-html": "^3.2.2"
|
||||
}
|
||||
}
|
||||
|
108
src/app.css
@ -1,108 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@fontsource/fira-mono';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
:root {
|
||||
--font-body:
|
||||
Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
--font-mono: 'Fira Mono', monospace;
|
||||
--color-bg-0: rgb(202, 216, 228);
|
||||
--color-bg-1: hsl(209, 36%, 86%);
|
||||
--color-bg-2: hsl(224, 44%, 95%);
|
||||
--color-theme-1: #ff3e00;
|
||||
--color-theme-2: #4075a6;
|
||||
--color-text: rgba(0, 0, 0, 0.7);
|
||||
--column-width: 42rem;
|
||||
--column-margin-top: 4rem;
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background-attachment: fixed;
|
||||
background-color: var(--color-bg-1);
|
||||
background-size: 100vw 100vh;
|
||||
background-image:
|
||||
radial-gradient(50% 50% at 50% 50%, rgba(255, 255, 255, 0.75) 0%, rgba(255, 255, 255, 0) 100%),
|
||||
linear-gradient(180deg, var(--color-bg-0) 0%, var(--color-bg-1) 15%, var(--color-bg-2) 50%);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-theme-1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 16px;
|
||||
font-family: var(--font-mono);
|
||||
background-color: rgba(255, 255, 255, 0.45);
|
||||
border-radius: 3px;
|
||||
box-shadow: 2px 2px 6px rgb(255 255 255 / 25%);
|
||||
padding: 0.5em;
|
||||
overflow-x: auto;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.text-column {
|
||||
display: flex;
|
||||
max-width: 48rem;
|
||||
flex: 0.6;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
h1 {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
35
src/app.html
@ -1,12 +1,45 @@
|
||||
<!--
|
||||
|
||||
___ _ _ ___ _ _
|
||||
/ __(_) |_| _ \___ _ __ ___ __(_) |_ ___ _ _ _ _
|
||||
| (_ | | _| / -_) '_ \/ _ (_-< | _/ _ \ '_| || | Mode %MODE%
|
||||
\___|_|\__|_|_\___| .__/\___/__/_|\__\___/_| \_, | Vite v<%= viteVersion %>
|
||||
|_| by github.com/kryscau |__/ SvelteKit v<%= sveltekitVersion %>
|
||||
-->
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/png" href="/assets/favicon/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/assets/favicon/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/assets/favicon/site.webmanifest" />
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/css/noscript.css" />
|
||||
</noscript>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="description" content="Svelte demo app" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<body class="bg-gh-bg text-gh-text min-h-screen" data-sveltekit-preload-data="hover">
|
||||
<noscript>
|
||||
<div class="noscript-message">
|
||||
<h1>Oups!</h1>
|
||||
<p>
|
||||
This application requires JavaScript to be enabled. Please enable JavaScript in your
|
||||
browser settings and refresh the page. <br />
|
||||
<a href="https://www.enable-javascript.com/" target="_blank" rel="noopener noreferrer">
|
||||
How to enable JavaScript?
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</noscript>
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
24
src/lib/components/MetaSEO.svelte
Normal file
@ -0,0 +1,24 @@
|
||||
<script>
|
||||
import { PUBLIC_GITHUB_USERNAME } from '$env/static/public';
|
||||
|
||||
export let title = 'Default Title';
|
||||
export let description = 'Default description for the page.';
|
||||
export let imageUrl = 'https://example.com/default-image.jpg';
|
||||
export let author = `Kryscau for @${PUBLIC_GITHUB_USERNAME}`;
|
||||
export let keywords = 'default, keywords, for, seo';
|
||||
export let robots = 'index, follow';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={imageUrl} />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={imageUrl} />
|
||||
<meta name="author" content={author} />
|
||||
<meta name="keywords" content={keywords} />
|
||||
<meta name="robots" content={robots} />
|
||||
</svelte:head>
|
58
src/lib/components/Navigation.svelte
Normal file
@ -0,0 +1,58 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { derived } from 'svelte/store';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let username = '';
|
||||
const formattedUsername = username.charAt(0).toUpperCase() + username.slice(1).toLowerCase();
|
||||
|
||||
const isRepositoryPage = derived(page, ($page) => {
|
||||
return (
|
||||
$page.url.pathname.startsWith('/repository/') || $page.url.hash.startsWith('#/repository/')
|
||||
);
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
history.back();
|
||||
}
|
||||
|
||||
// (Optionnel) Menu mobile toggle si besoin
|
||||
onMount(() => {
|
||||
const menuButton = document.querySelector('button[data-toggle="mobile-menu"]');
|
||||
if (menuButton) {
|
||||
menuButton.addEventListener('click', () => {
|
||||
console.log('Mobile menu clicked');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="gh-bg-secondary gh-border sticky top-0 z-50 border-b"
|
||||
style="border-bottom: 1px solid var(--gh-border)"
|
||||
>
|
||||
<div class="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<a href="/" rel="noopener nofollow">
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fab fa-github text-gh-text text-2xl"></i>
|
||||
<span class="text-gh-text text-xl font-semibold">
|
||||
<span id="username">{formattedUsername}</span>'s Repositories
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{#if $isRepositoryPage}
|
||||
<div class="flex items-center space-x-3 text-white">
|
||||
<button
|
||||
on:click={goBack}
|
||||
class="gh-accent flex items-center space-x-2 text-white transition-colors hover:opacity-80"
|
||||
>
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span class="hidden text-white sm:inline">Back to Repositories</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
116
src/lib/components/ProfileSection.svelte
Normal file
@ -0,0 +1,116 @@
|
||||
<script>
|
||||
export let nickname = '';
|
||||
export let username = '';
|
||||
export let avatarUrl = '';
|
||||
export let stats = { repositories: 0, followers: 0, following: 0 };
|
||||
export let bio = '';
|
||||
export let location = '';
|
||||
export let blog = '';
|
||||
export let company = '';
|
||||
</script>
|
||||
|
||||
<!-- Profile Section -->
|
||||
<div
|
||||
class="gh-bg-secondary mb-8 rounded-2xl border p-6 sm:p-8"
|
||||
style="border: 1px solid var(--gh-border)"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center space-y-6 lg:flex-row lg:items-start lg:space-y-0 lg:space-x-8"
|
||||
>
|
||||
<!-- Left side: Avatar + Info -->
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center space-y-4 sm:flex-row sm:items-start sm:space-y-0 sm:space-x-6"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative">
|
||||
<img
|
||||
src={avatarUrl || '/assets/img/placeholder.jpg'}
|
||||
alt={`${username || 'Placeholder'}'s avatar`}
|
||||
draggable="false"
|
||||
class="h-24 w-24 rounded-full border-4 sm:h-32 sm:w-32"
|
||||
style="border-color: var(--gh-border)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Profile Info -->
|
||||
<div class="flex-1 text-center sm:text-left">
|
||||
{#if nickname}
|
||||
<h1 class="text-gh-text mb-2 text-2xl font-bold sm:text-3xl">{nickname}</h1>
|
||||
{/if}
|
||||
|
||||
{#if username}
|
||||
<p class="text-gh-text-secondary mb-3">@{username}</p>
|
||||
{/if}
|
||||
|
||||
{#if location || blog || company}
|
||||
<div
|
||||
class="gh-text-secondary mb-4 flex flex-wrap items-center justify-center gap-4 text-sm sm:justify-start"
|
||||
>
|
||||
{#if location}
|
||||
<div class="flex items-center space-x-1">
|
||||
<i class="fas fa-map-marker-alt gh-text-muted"></i>
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if company}
|
||||
<div class="flex items-center space-x-1">
|
||||
<i class="fas fa-building gh-text-muted"></i>
|
||||
<span>{company}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if blog}
|
||||
<div class="flex items-center space-x-1">
|
||||
<i class="fas fa-blog gh-text-muted"></i>
|
||||
<a
|
||||
href={blog}
|
||||
target="_blank"
|
||||
title="Visit my blog"
|
||||
rel="noopener noreferrer"
|
||||
class="transition-colors duration-300 hover:text-white"
|
||||
>
|
||||
<span>{blog.replace(/^https?:\/\/(www\.)?/, '')}</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if bio}
|
||||
<p class="text-gh-text-secondary mb-6 max-w-md" title={bio}>{bio}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Stats boxes -->
|
||||
<div class="flex flex-col space-y-3 lg:min-w-[200px]">
|
||||
<!-- Repos Box -->
|
||||
<div
|
||||
class="gh-bg-tertiary hover:border-gh-accent rounded-lg border p-1 text-center transition-colors"
|
||||
style="border: 1px solid var(--gh-border)"
|
||||
>
|
||||
<div class="gh-text text-2xl font-bold">{stats.repositories}</div>
|
||||
<div class="gh-text-secondary text-sm">Owned Repos</div>
|
||||
</div>
|
||||
|
||||
<!-- Followers Box -->
|
||||
<div
|
||||
class="gh-bg-tertiary hover:border-gh-accent rounded-lg border p-1 text-center transition-colors"
|
||||
style="border: 1px solid var(--gh-border)"
|
||||
>
|
||||
<div class="gh-text text-2xl font-bold">{stats.followers}</div>
|
||||
<div class="gh-text-secondary text-sm">Followers</div>
|
||||
</div>
|
||||
|
||||
<!-- Following Box -->
|
||||
<div
|
||||
class="gh-bg-tertiary hover:border-gh-accent rounded-lg border p-1 text-center transition-colors"
|
||||
style="border: 1px solid var(--gh-border)"
|
||||
>
|
||||
<div class="gh-text text-2xl font-bold">{stats.following}</div>
|
||||
<div class="gh-text-secondary text-sm">Following</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
37
src/lib/components/RepositorySection.svelte
Normal file
@ -0,0 +1,37 @@
|
||||
<script>
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import LangBadge from './ui/LangBadge.svelte';
|
||||
import RepoCard from './ui/RepoCard.svelte';
|
||||
|
||||
/**
|
||||
* @type {any[]}
|
||||
*/
|
||||
export let repositories = [];
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return 'Unknown date';
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="gh-bg-secondary rounded-2xl border p-6 sm:p-8"
|
||||
style="border: 1px solid var(--gh-border)"
|
||||
>
|
||||
<div class="mb-6 flex items-center space-x-3">
|
||||
<i class="fas fa-folder-open text-gh-accent text-xl"></i>
|
||||
<h2 class="text-gh-text text-xl font-bold sm:text-2xl">My public repositories</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each repositories as repo (repo.id)}
|
||||
<RepoCard {repo} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.repo-card:hover {
|
||||
border-color: rgba(var(--gh-accent-rgba), 0.5) !important;
|
||||
}
|
||||
</style>
|
36
src/lib/components/ui/LangBadge.svelte
Normal file
@ -0,0 +1,36 @@
|
||||
<script>
|
||||
import colors from '$lib/data/colors.json';
|
||||
|
||||
export let lang = '';
|
||||
export let percent = 0;
|
||||
export let mode = 'badge'; // badge ou bar
|
||||
|
||||
// couleur récupérée dans le JSON
|
||||
const color = colors[lang]?.color || '#4b5563'; // gris par défaut
|
||||
|
||||
// opacity 20%
|
||||
const opacity20 = color + '33';
|
||||
</script>
|
||||
|
||||
{#if mode === 'badge'}
|
||||
<div
|
||||
class="inline-flex items-center space-x-1 rounded px-2 py-0.5 text-sm font-medium"
|
||||
style="background-color: {opacity20}; color: {color};"
|
||||
>
|
||||
<span class="h-2.5 w-2.5 rounded-full" style="background-color: {color};"></span>
|
||||
<span>{lang}</span>
|
||||
</div>
|
||||
{:else if mode === 'bar'}
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="h-3 w-3 rounded-full" style="background-color: {color};"></div>
|
||||
<span class="gh-text font-medium">{lang}</span>
|
||||
</div>
|
||||
<span class="gh-text-secondary text-sm">{percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class="bg-gh-border-muted h-2 w-full rounded-full">
|
||||
<div class="h-2 rounded-full" style="width: {percent}%; background-color: {color};"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
191
src/lib/components/ui/RepoCard.svelte
Normal file
@ -0,0 +1,191 @@
|
||||
<script>
|
||||
import LangBadge from './LangBadge.svelte';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
export let repo;
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return 'Unknown date';
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true });
|
||||
};
|
||||
|
||||
const repoFullName = repo.full_name || `${repo.owner.login}/${repo.name}`;
|
||||
|
||||
const repoUrl = `https://github.com/${repoFullName}`;
|
||||
const stargazersUrl = `${repoUrl}/stargazers`;
|
||||
const forksUrl = `${repoUrl}/network/members`;
|
||||
const langSearchUrl = `https://github.com/search?q=user:${repo.owner.login}+language:${repo.language}`;
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="repo-card bg-gh-bg-secondary border-gh-border hover:border-gh-accent relative cursor-default rounded-2xl border p-6 shadow-md transition-shadow hover:shadow-lg"
|
||||
aria-label={`Repository card for ${repoFullName}`}
|
||||
>
|
||||
<header class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-gh-accent flex max-w-full items-center gap-2 truncate text-lg font-semibold">
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
<a
|
||||
href={`/repository/${repoFullName.toLowerCase()}`}
|
||||
rel="noopener noreferrer"
|
||||
title={`Open ${repoFullName}'s summary`}
|
||||
>
|
||||
<span
|
||||
class="text-gh-accent hover:text-gh-accent-emphasis cursor-help"
|
||||
title="Click to see detailed summary on this site"
|
||||
aria-label="Repository summary available"
|
||||
>
|
||||
<i class="fas fa-eye"></i>
|
||||
</span>
|
||||
</a>
|
||||
{repoFullName}
|
||||
</div>
|
||||
<div class="text-gh-text-secondary mt-3 flex space-x-5 text-sm sm:mt-0">
|
||||
<a
|
||||
href={stargazersUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-gh-attention flex cursor-pointer items-center space-x-1 transition-colors"
|
||||
title="View stargazers"
|
||||
>
|
||||
<i class="fas fa-star"></i>
|
||||
<span>{repo.stargazers_count || 0}</span>
|
||||
</a>
|
||||
<a
|
||||
href={forksUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-gh-accent flex cursor-pointer items-center space-x-1 transition-colors"
|
||||
title="View forks"
|
||||
>
|
||||
<i class="fas fa-code-branch"></i>
|
||||
<span>{repo.forks_count || 0}</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="text-gh-text-secondary min-h-[3rem]">
|
||||
{repo.description || 'No description has been set.'}
|
||||
</p>
|
||||
|
||||
<footer
|
||||
class="text-gh-text-secondary flex flex-col items-center justify-between text-sm sm:flex-row"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2 sm:flex-row">
|
||||
{#if repo.language}
|
||||
<a
|
||||
href={langSearchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-gh-accent flex cursor-pointer items-center space-x-2 transition-colors"
|
||||
title={`See all repos by language: ${repo.language}`}
|
||||
>
|
||||
<LangBadge lang={repo.language} />
|
||||
</a>
|
||||
{/if}
|
||||
{#if repo.updated_at}
|
||||
<span title={`Last updated on ${new Date(repo.updated_at).toLocaleDateString()}`}>
|
||||
Updated {formatDate(repo.updated_at)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if repo.created_at}
|
||||
<span title={`Created on ${new Date(repo.created_at).toLocaleDateString()}`}>
|
||||
Created {formatDate(repo.created_at)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-4" role="group" aria-label="Repository external links">
|
||||
{#if repo.html_url}
|
||||
<a href={repo.html_url} target="_blank" rel="noopener noreferrer">
|
||||
<button
|
||||
class="gh-bg-tertiary gh-text flex items-center justify-center space-x-2 rounded-lg border px-4 py-2 transition-colors hover:bg-gray-600"
|
||||
style="border: 1px solid var(--gh-border)"
|
||||
type="button"
|
||||
>
|
||||
<i class="fas fa-code"></i>
|
||||
<span>View Code</span>
|
||||
</button>
|
||||
</a>
|
||||
{/if}
|
||||
{#if repo.homepage}
|
||||
<a href={repo.homepage} target="_blank" rel="noopener noreferrer">
|
||||
<button
|
||||
class="bg-gh-accent-emphasis hover:bg-gh-accent flex items-center justify-center space-x-2 rounded-lg px-4 py-2 text-white transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
<span>View Site</span>
|
||||
</button>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.repo-card {
|
||||
background-color: var(--gh-bg-secondary);
|
||||
border-color: var(--gh-border);
|
||||
color: var(--gh-text-secondary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.repo-card:hover {
|
||||
border-color: var(--gh-accent);
|
||||
box-shadow: 0 8px 16px rgba(59, 130, 246, 0.3);
|
||||
color: var(--gh-text);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Styles pour les écrans de PC */
|
||||
@media (min-width: 641px) {
|
||||
.repo-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.repo-card footer {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.repo-card footer div[role='group'] {
|
||||
flex-direction: row;
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Styles pour les écrans de téléphone */
|
||||
@media (max-width: 640px) {
|
||||
.repo-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.repo-card header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.repo-card footer {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.repo-card footer div:first-child {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.repo-card footer div[role='group'] {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.repo-card footer button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
105
src/lib/css/main.css
Normal file
@ -0,0 +1,105 @@
|
||||
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css');
|
||||
#username::first-letter {
|
||||
text-transform: capitalize !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
--gh-bg: #0d1117;
|
||||
--gh-bg-secondary: #161b22;
|
||||
--gh-bg-tertiary: #21262d;
|
||||
--gh-border: #30363d;
|
||||
--gh-border-muted: #21262d;
|
||||
--gh-text: #f0f6fc;
|
||||
--gh-text-secondary: #7d8590;
|
||||
--gh-text-muted: #656d76;
|
||||
--gh-accent: #58a6ff;
|
||||
--gh-accent-emphasis: #1f6feb;
|
||||
--gh-success: #3fb950;
|
||||
--gh-attention: #d29922;
|
||||
--gh-severe: #db6d28;
|
||||
--gh-danger: #f85149;
|
||||
}
|
||||
|
||||
.gh-bg {
|
||||
background-color: var(--gh-bg);
|
||||
}
|
||||
.gh-bg-secondary {
|
||||
background-color: var(--gh-bg-secondary);
|
||||
}
|
||||
.gh-bg-tertiary {
|
||||
background-color: var(--gh-bg-tertiary);
|
||||
}
|
||||
.gh-border {
|
||||
border-color: var(--gh-border);
|
||||
}
|
||||
.gh-border-muted {
|
||||
border-color: var(--gh-border-muted);
|
||||
}
|
||||
.gh-text {
|
||||
color: var(--gh-text);
|
||||
}
|
||||
.gh-text-secondary {
|
||||
color: var(--gh-text-secondary);
|
||||
}
|
||||
.gh-text-muted {
|
||||
color: var(--gh-text-muted);
|
||||
}
|
||||
.gh-accent {
|
||||
color: var(--gh-accent);
|
||||
}
|
||||
.gh-accent-emphasis {
|
||||
color: var(--gh-accent-emphasis);
|
||||
}
|
||||
.gh-success {
|
||||
color: var(--gh-success);
|
||||
}
|
||||
.gh-attention {
|
||||
color: var(--gh-attention);
|
||||
}
|
||||
.gh-danger {
|
||||
color: var(--gh-danger);
|
||||
}
|
||||
|
||||
.bg-gh-accent {
|
||||
background-color: var(--gh-accent);
|
||||
}
|
||||
.bg-gh-accent-emphasis {
|
||||
background-color: var(--gh-accent-emphasis);
|
||||
}
|
||||
.bg-gh-success {
|
||||
background-color: var(--gh-success);
|
||||
}
|
||||
.bg-gh-attention {
|
||||
background-color: var(--gh-attention);
|
||||
}
|
||||
.bg-gh-danger {
|
||||
background-color: var(--gh-danger);
|
||||
}
|
||||
|
||||
.border-gh-accent {
|
||||
border-color: var(--gh-accent);
|
||||
}
|
||||
.border-gh-success {
|
||||
border-color: var(--gh-success);
|
||||
}
|
||||
.border-gh-attention {
|
||||
border-color: var(--gh-attention);
|
||||
}
|
||||
.border-gh-danger {
|
||||
border-color: var(--gh-danger);
|
||||
}
|
||||
|
||||
.hover\:border-gh-accent:hover {
|
||||
border-color: var(--gh-accent);
|
||||
}
|
||||
.hover\:bg-gh-accent:hover {
|
||||
background-color: var(--gh-accent);
|
||||
}
|
||||
.hover\:bg-gh-accent-emphasis:hover {
|
||||
background-color: var(--gh-accent-emphasis);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--gh-bg);
|
||||
color: var(--gh-text);
|
||||
}
|
3
src/lib/css/tailwind.css
Normal file
@ -0,0 +1,3 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
2742
src/lib/data/colors.json
Normal file
@ -1 +0,0 @@
|
||||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
|
Before Width: | Height: | Size: 963 B |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.1566,22.8189c-10.4-14.8851-30.94-19.2971-45.7914-9.8348L22.2825,29.6078A29.9234,29.9234,0,0,0,8.7639,49.6506a31.5136,31.5136,0,0,0,3.1076,20.2318A30.0061,30.0061,0,0,0,7.3953,81.0653a31.8886,31.8886,0,0,0,5.4473,24.1157c10.4022,14.8865,30.9423,19.2966,45.7914,9.8348L84.7167,98.3921A29.9177,29.9177,0,0,0,98.2353,78.3493,31.5263,31.5263,0,0,0,95.13,58.117a30,30,0,0,0,4.4743-11.1824,31.88,31.88,0,0,0-5.4473-24.1157" style="fill:#ff3e00"/><path d="M45.8171,106.5815A20.7182,20.7182,0,0,1,23.58,98.3389a19.1739,19.1739,0,0,1-3.2766-14.5025,18.1886,18.1886,0,0,1,.6233-2.4357l.4912-1.4978,1.3363.9815a33.6443,33.6443,0,0,0,10.203,5.0978l.9694.2941-.0893.9675a5.8474,5.8474,0,0,0,1.052,3.8781,6.2389,6.2389,0,0,0,6.6952,2.485,5.7449,5.7449,0,0,0,1.6021-.7041L69.27,76.281a5.4306,5.4306,0,0,0,2.4506-3.631,5.7948,5.7948,0,0,0-.9875-4.3712,6.2436,6.2436,0,0,0-6.6978-2.4864,5.7427,5.7427,0,0,0-1.6.7036l-9.9532,6.3449a19.0329,19.0329,0,0,1-5.2965,2.3259,20.7181,20.7181,0,0,1-22.2368-8.2427,19.1725,19.1725,0,0,1-3.2766-14.5024,17.9885,17.9885,0,0,1,8.13-12.0513L55.8833,23.7472a19.0038,19.0038,0,0,1,5.3-2.3287A20.7182,20.7182,0,0,1,83.42,29.6611a19.1739,19.1739,0,0,1,3.2766,14.5025,18.4,18.4,0,0,1-.6233,2.4357l-.4912,1.4978-1.3356-.98a33.6175,33.6175,0,0,0-10.2037-5.1l-.9694-.2942.0893-.9675a5.8588,5.8588,0,0,0-1.052-3.878,6.2389,6.2389,0,0,0-6.6952-2.485,5.7449,5.7449,0,0,0-1.6021.7041L37.73,51.719a5.4218,5.4218,0,0,0-2.4487,3.63,5.7862,5.7862,0,0,0,.9856,4.3717,6.2437,6.2437,0,0,0,6.6978,2.4864,5.7652,5.7652,0,0,0,1.602-.7041l9.9519-6.3425a18.978,18.978,0,0,1,5.2959-2.3278,20.7181,20.7181,0,0,1,22.2368,8.2427,19.1725,19.1725,0,0,1,3.2766,14.5024,17.9977,17.9977,0,0,1-8.13,12.0532L51.1167,104.2528a19.0038,19.0038,0,0,1-5.3,2.3287" style="fill:#fff"/></svg>
|
Before Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 352 KiB |
Before Width: | Height: | Size: 113 KiB |
50
src/routes/+error.svelte
Normal file
@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<div class="not-found">
|
||||
<h1>{page.error.message}</h1>
|
||||
<a href="/" class="btn-home" aria-label="Retour à l'accueil">Back to homepage</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.not-found {
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--gh-text-primary, #333);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.not-found h1 {
|
||||
font-size: 6rem;
|
||||
font-weight: 900;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--gh-accent, #ff4500);
|
||||
}
|
||||
|
||||
.not-found p {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--gh-text-secondary, #666);
|
||||
}
|
||||
|
||||
.btn-home {
|
||||
background-color: var(--gh-accent-emphasis, #ff6347);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-home:hover {
|
||||
background-color: var(--gh-accent, #ff4500);
|
||||
}
|
||||
</style>
|
@ -1,57 +1,37 @@
|
||||
<script>
|
||||
import Header from './Header.svelte';
|
||||
import '../app.css';
|
||||
import '$lib/css/tailwind.css';
|
||||
import '$lib/css/main.css';
|
||||
|
||||
import { page } from '$app/stores';
|
||||
import { derived } from 'svelte/store';
|
||||
import Navigation from '$lib/components/Navigation.svelte';
|
||||
import { PUBLIC_GITHUB_USERNAME } from '$env/static/public';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const canonicalUrl = derived(page, ($page) => {
|
||||
// Ex : https://username.github.io/#/path
|
||||
// Remplace '/' au début
|
||||
const path = $page.url.pathname.replace(/^\//, '');
|
||||
return `https://${PUBLIC_GITHUB_USERNAME}.github.io/${path}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
<Header />
|
||||
<svelte:head>
|
||||
<link rel="canonical" href={$canonicalUrl} />
|
||||
<meta property="og:url" content={$canonicalUrl} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="app">
|
||||
<Navigation username={PUBLIC_GITHUB_USERNAME} />
|
||||
<div class="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="gh-bg-secondary mb-8 rounded-2xl border p-6 sm:p-8"
|
||||
style="border: 1px solid var(--gh-border)"
|
||||
>
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to learn about SvelteKit
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
footer a {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
footer {
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,3 +0,0 @@
|
||||
// since there's no dynamic data here, we can prerender
|
||||
// it so that it gets served as a static asset in production
|
||||
export const prerender = true;
|
@ -1,59 +1,91 @@
|
||||
<script>
|
||||
import Counter from './Counter.svelte';
|
||||
import welcome from '$lib/images/svelte-welcome.webp';
|
||||
import welcomeFallback from '$lib/images/svelte-welcome.png';
|
||||
// @ts-nocheck
|
||||
|
||||
import MetaSeo from '$lib/components/MetaSEO.svelte';
|
||||
import ProfileSection from '$lib/components/ProfileSection.svelte';
|
||||
import RepositorySection from '$lib/components/RepositorySection.svelte';
|
||||
import { PUBLIC_GITHUB_USERNAME, PUBLIC_GITHUB_API_URL } from '$env/static/public';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let user = {
|
||||
nickname: PUBLIC_GITHUB_USERNAME,
|
||||
username: PUBLIC_GITHUB_USERNAME,
|
||||
bio: '',
|
||||
location: '',
|
||||
blog: '',
|
||||
company: '',
|
||||
avatarUrl: '/assets/img/placeholder.jpg',
|
||||
stats: { repositories: 0, followers: 0, following: 0 }
|
||||
};
|
||||
|
||||
let repositories = [];
|
||||
|
||||
onMount(async () => {
|
||||
const BASE_URL = PUBLIC_GITHUB_API_URL;
|
||||
const USERNAME = PUBLIC_GITHUB_USERNAME;
|
||||
|
||||
try {
|
||||
// Appel public pour récupérer les infos du user
|
||||
const userResponse = await fetch(`${BASE_URL}/users/${USERNAME}`);
|
||||
if (!userResponse.ok) throw new Error('Error recovering user data');
|
||||
const userData = await userResponse.json();
|
||||
|
||||
user.nickname = userData.name || userData.login[0].toUpperCase() + userData.login.slice(1);
|
||||
user.username = userData.login;
|
||||
user.avatarUrl = userData.avatar_url;
|
||||
user.stats = {
|
||||
repositories: userData.public_repos,
|
||||
followers: userData.followers,
|
||||
following: userData.following
|
||||
};
|
||||
user.bio = userData.bio;
|
||||
user.location = userData.location;
|
||||
user.blog = userData.blog;
|
||||
user.company = userData.company;
|
||||
|
||||
// Appel public pour récupérer les repos
|
||||
const reposResponse = await fetch(`${BASE_URL}/users/${USERNAME}/repos?per_page=100`);
|
||||
if (!reposResponse.ok) throw new Error('Error retrieving repositories');
|
||||
const reposData = await reposResponse.json();
|
||||
|
||||
repositories = reposData.filter(
|
||||
(/** @type {{ name: string; description: string; }} */ repo) => {
|
||||
const forbiddenNames = ['.github', 'DiscussionsHost'];
|
||||
const hasForbiddenWordInName = ['demo', 'backup', 'test', 'old'].some((word) =>
|
||||
repo.name.toLowerCase().includes(word)
|
||||
);
|
||||
const hasForbiddenDescription =
|
||||
repo.description && repo.description.toLowerCase().includes('just a redirect');
|
||||
|
||||
return (
|
||||
!forbiddenNames.includes(repo.name) &&
|
||||
!hasForbiddenWordInName &&
|
||||
!hasForbiddenDescription
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
console.log(repositories);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving GitHub data:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Home</title>
|
||||
<meta name="description" content="Svelte demo app" />
|
||||
</svelte:head>
|
||||
<MetaSeo
|
||||
title={`Repositories — ${user.nickname} (@${user.username})`}
|
||||
description={`Discover all the GitHub Repositories and primary statistics for ${user.nickname} (@${user.username}) on this page.`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h1>
|
||||
<span class="welcome">
|
||||
<picture>
|
||||
<source srcset={welcome} type="image/webp" />
|
||||
<img src={welcomeFallback} alt="Welcome" />
|
||||
</picture>
|
||||
</span>
|
||||
<ProfileSection
|
||||
nickname={user.nickname}
|
||||
username={user.username}
|
||||
avatarUrl={user.avatarUrl}
|
||||
stats={user.stats}
|
||||
bio={user.bio}
|
||||
location={user.location}
|
||||
blog={user.blog}
|
||||
company={user.company}
|
||||
/>
|
||||
|
||||
to your new<br />SvelteKit app
|
||||
</h1>
|
||||
|
||||
<h2>
|
||||
try editing <strong>src/routes/+page.svelte</strong>
|
||||
</h2>
|
||||
|
||||
<Counter />
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 0.6;
|
||||
}
|
||||
|
||||
h1 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding: 0 0 calc(100% * 495 / 2048) 0;
|
||||
}
|
||||
|
||||
.welcome img {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<RepositorySection {repositories} />
|
||||
|
@ -1,103 +0,0 @@
|
||||
<script>
|
||||
import { Spring } from 'svelte/motion';
|
||||
|
||||
const count = new Spring(0);
|
||||
const offset = $derived(modulo(count.current, 1));
|
||||
|
||||
/**
|
||||
* @param {number} n
|
||||
* @param {number} m
|
||||
*/
|
||||
function modulo(n, m) {
|
||||
// handle negative numbers
|
||||
return ((n % m) + m) % m;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="counter">
|
||||
<button onclick={() => (count.target -= 1)} aria-label="Decrease the counter by one">
|
||||
<svg aria-hidden="true" viewBox="0 0 1 1">
|
||||
<path d="M0,0.5 L1,0.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="counter-viewport">
|
||||
<div class="counter-digits" style="transform: translate(0, {100 * offset}%)">
|
||||
<strong class="hidden" aria-hidden="true">{Math.floor(count.current + 1)}</strong>
|
||||
<strong>{Math.floor(count.current)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick={() => (count.target += 1)} aria-label="Increase the counter by one">
|
||||
<svg aria-hidden="true" viewBox="0 0 1 1">
|
||||
<path d="M0,0.5 L1,0.5 M0.5,0 L0.5,1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.counter {
|
||||
display: flex;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.counter button {
|
||||
width: 2em;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
touch-action: manipulation;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.counter button:hover {
|
||||
background-color: var(--color-bg-1);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 25%;
|
||||
height: 25%;
|
||||
}
|
||||
|
||||
path {
|
||||
vector-effect: non-scaling-stroke;
|
||||
stroke-width: 2px;
|
||||
stroke: #444;
|
||||
}
|
||||
|
||||
.counter-viewport {
|
||||
width: 8em;
|
||||
height: 4em;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.counter-viewport strong {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: 400;
|
||||
color: var(--color-theme-1);
|
||||
font-size: 4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.counter-digits {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
top: -100%;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
@ -1,129 +0,0 @@
|
||||
<script>
|
||||
import { page } from '$app/state';
|
||||
import logo from '$lib/images/svelte-logo.svg';
|
||||
import github from '$lib/images/github.svg';
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<div class="corner">
|
||||
<a href="https://svelte.dev/docs/kit">
|
||||
<img src={logo} alt="SvelteKit" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<svg viewBox="0 0 2 3" aria-hidden="true">
|
||||
<path d="M0,0 L1,2 C1.5,3 1.5,3 2,3 L2,0 Z" />
|
||||
</svg>
|
||||
<ul>
|
||||
<li aria-current={page.url.pathname === '/' ? 'page' : undefined}>
|
||||
<a href="/">Home</a>
|
||||
</li>
|
||||
<li aria-current={page.url.pathname === '/about' ? 'page' : undefined}>
|
||||
<a href="/about">About</a>
|
||||
</li>
|
||||
<li aria-current={page.url.pathname.startsWith('/sverdle') ? 'page' : undefined}>
|
||||
<a href="/sverdle">Sverdle</a>
|
||||
</li>
|
||||
</ul>
|
||||
<svg viewBox="0 0 2 3" aria-hidden="true">
|
||||
<path d="M0,0 L0,3 C0.5,3 0.5,3 1,2 L2,0 Z" />
|
||||
</svg>
|
||||
</nav>
|
||||
|
||||
<div class="corner">
|
||||
<a href="https://github.com/sveltejs/kit">
|
||||
<img src={github} alt="GitHub" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.corner {
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
}
|
||||
|
||||
.corner a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.corner img {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
--background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 2em;
|
||||
height: 3em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
path {
|
||||
fill: var(--background);
|
||||
}
|
||||
|
||||
ul {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: 3em;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
background: var(--background);
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
li[aria-current='page']::before {
|
||||
--size: 6px;
|
||||
content: '';
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(50% - var(--size));
|
||||
border: var(--size) solid transparent;
|
||||
border-top: var(--size) solid var(--color-theme-1);
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s linear;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-theme-1);
|
||||
}
|
||||
</style>
|
@ -1,9 +0,0 @@
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
// we don't need any JS on this page, though we'll load
|
||||
// it in dev so that we get hot module replacement
|
||||
export const csr = dev;
|
||||
|
||||
// since there's no dynamic data here, we can prerender
|
||||
// it so that it gets served as a static asset in production
|
||||
export const prerender = true;
|
@ -1,26 +0,0 @@
|
||||
<svelte:head>
|
||||
<title>About</title>
|
||||
<meta name="description" content="About this app" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="text-column">
|
||||
<h1>About this app</h1>
|
||||
|
||||
<p>
|
||||
This is a <a href="https://svelte.dev/docs/kit">SvelteKit</a> app. You can make your own by typing
|
||||
the following into your command line and following the prompts:
|
||||
</p>
|
||||
|
||||
<pre>npx sv create</pre>
|
||||
|
||||
<p>
|
||||
The page you're looking at is purely static HTML, with no client-side interactivity needed.
|
||||
Because of that, we don't need to load any JavaScript. Try viewing the page's source, or opening
|
||||
the devtools network panel and reloading.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The <a href="/sverdle">Sverdle</a> page illustrates SvelteKit's data loading and form handling. Try
|
||||
using it with JavaScript disabled!
|
||||
</p>
|
||||
</div>
|
275
src/routes/repository/[username]/[repo]/+page.svelte
Normal file
@ -0,0 +1,275 @@
|
||||
<script>
|
||||
// @ts-nocheck
|
||||
|
||||
import { PUBLIC_GITHUB_API_URL } from '$env/static/public';
|
||||
import { page } from '$app/stores';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
let username;
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
let repo;
|
||||
|
||||
const unsubscribe = page.subscribe(($page) => {
|
||||
username = $page.params.username;
|
||||
repo = $page.params.repo;
|
||||
});
|
||||
|
||||
onDestroy(() => unsubscribe());
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import LangBadge from '$lib/components/ui/LangBadge.svelte'; // adapte selon ton dossier
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
let dataRepo = {
|
||||
repository: null,
|
||||
languages: {},
|
||||
contributors: []
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {null}
|
||||
*/
|
||||
let error = null;
|
||||
|
||||
const fetchData = async () => {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const repoRes = await fetch(`${PUBLIC_GITHUB_API_URL}/repos/${username}/${repo}`);
|
||||
if (!repoRes.ok) throw new Error('Unable to retrieve info from repo');
|
||||
const repoData = await repoRes.json();
|
||||
|
||||
const langRes = await fetch(`${PUBLIC_GITHUB_API_URL}/repos/${username}/${repo}/languages`);
|
||||
if (!langRes.ok) throw new Error('Unable to retrieve languages');
|
||||
const languagesData = await langRes.json();
|
||||
|
||||
const contRes = await fetch(
|
||||
`${PUBLIC_GITHUB_API_URL}/repos/${username}/${repo}/contributors`
|
||||
);
|
||||
if (!contRes.ok) throw new Error('Unable to retrieve contributors');
|
||||
const contributorsData = await contRes.json();
|
||||
|
||||
// Calcul pourcentages langages
|
||||
const total = Object.values(languagesData).reduce((a, b) => a + b, 0);
|
||||
const languagesWithPercent = {};
|
||||
for (const [lang, bytes] of Object.entries(languagesData)) {
|
||||
languagesWithPercent[lang] = (bytes / total) * 100;
|
||||
}
|
||||
|
||||
dataRepo = {
|
||||
repository: repoData,
|
||||
languages: languagesWithPercent,
|
||||
contributors: contributorsData
|
||||
};
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
dataRepo = { repository: null, languages: {}, contributors: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return 'Date inconnue';
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true });
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
fetchData();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="mb-6 rounded bg-red-600 p-4 text-white">
|
||||
⚠️ {error} — Try again later, you may have exceeded the GitHub API limit without a token.
|
||||
</div>
|
||||
{:else if !dataRepo.repository}
|
||||
<div>Loading...</div>
|
||||
{:else}
|
||||
<div
|
||||
class="gh-bg-secondary mb-8 rounded-2xl border p-6 sm:p-8"
|
||||
style="border: 1px solid var(--gh-border)"
|
||||
>
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="mb-4 flex items-center space-x-3 sm:mb-0">
|
||||
<i class="fas fa-folder-open gh-accent text-2xl"></i>
|
||||
<div>
|
||||
<h1 class="gh-text text-2xl font-bold sm:text-3xl">
|
||||
{dataRepo?.repository?.full_name}
|
||||
</h1>
|
||||
<span
|
||||
class="gh-success mt-3 inline-block rounded-full border px-2 py-1 text-xs"
|
||||
style="background-color: rgba(63, 185, 80, 0.2); border-color: rgba(63, 185, 80, 0.3)"
|
||||
>
|
||||
{dataRepo?.repository?.visibility?.toUpperCase()}
|
||||
</span>
|
||||
{#if dataRepo?.repository?.archived}
|
||||
<span
|
||||
class="mt-3 ml-2 inline-block rounded-full border bg-orange-700/25 px-2 py-1 text-xs text-orange-500"
|
||||
>
|
||||
ARCHIVED
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
{#if dataRepo?.repository?.html_url}
|
||||
<a href={dataRepo?.repository?.html_url} target="_blank" rel="noopener noreferrer">
|
||||
<button
|
||||
class="gh-bg-tertiary gh-text flex items-center justify-center space-x-2 rounded-lg border px-4 py-2 transition-colors hover:bg-gray-600"
|
||||
style="border: 1px solid var(--gh-border)"
|
||||
type="button"
|
||||
>
|
||||
<i class="fas fa-code"></i>
|
||||
<span>View Code</span>
|
||||
</button>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if dataRepo?.repository?.homepage}
|
||||
<a href={dataRepo?.repository?.homepage} target="_blank" rel="noopener noreferrer">
|
||||
<button
|
||||
class="bg-gh-accent-emphasis hover:bg-gh-accent flex items-center justify-center space-x-2 rounded-lg px-4 py-2 text-white transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
<span>View Site</span>
|
||||
</button>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="gh-text-secondary mb-4">
|
||||
{dataRepo?.repository?.description || 'No description has been set.'}
|
||||
</p>
|
||||
|
||||
{#if dataRepo?.repository?.created_at || dataRepo?.repository?.updated_at}
|
||||
<div class="gh-text-secondary flex flex-wrap items-center gap-4 text-sm">
|
||||
{#if dataRepo?.repository?.created_at}
|
||||
<div class="flex items-center space-x-1">
|
||||
<i class="fas fa-calendar-plus gh-text-muted"></i>
|
||||
<span>Created {formatDate(dataRepo?.repository?.created_at)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if dataRepo?.repository?.updated_at}
|
||||
<div class="flex items-center space-x-1">
|
||||
<i class="fas fa-sync-alt gh-text-muted"></i>
|
||||
<span>Updated {formatDate(dataRepo?.repository?.updated_at)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<!-- Statistics -->
|
||||
<div class="lg:col-span-2">
|
||||
<div
|
||||
class="gh-bg-secondary mb-8 rounded-2xl border p-6 sm:p-8"
|
||||
style="border: 1px solid var(--gh-border)"
|
||||
>
|
||||
<div class="mb-6 flex items-center space-x-3">
|
||||
<i class="fas fa-chart-bar gh-accent text-xl"></i>
|
||||
<h2 class="gh-text text-xl font-bold">Statistics</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div
|
||||
class="gh-bg-tertiary stat-card stars rounded-xl border border-[var(--gh-border)] p-4 text-center transition-colors hover:border-yellow-400"
|
||||
>
|
||||
<i class="fas fa-star gh-attention mb-2 text-2xl"></i>
|
||||
<div class="gh-text text-2xl font-bold">{dataRepo?.repository?.stargazers_count}</div>
|
||||
<div class="gh-text-secondary text-sm">Stars</div>
|
||||
</div>
|
||||
<div
|
||||
class="gh-bg-tertiary stat-card forks rounded-xl border border-[var(--gh-border)] p-4 text-center transition-colors hover:border-blue-400"
|
||||
>
|
||||
<i class="fas fa-code-branch gh-accent mb-2 text-2xl"></i>
|
||||
<div class="gh-text text-2xl font-bold">{dataRepo?.repository?.forks_count}</div>
|
||||
<div class="gh-text-secondary text-sm">Forks</div>
|
||||
</div>
|
||||
<div
|
||||
class="gh-bg-tertiary stat-card watchers rounded-xl border border-[var(--gh-border)] p-4 text-center transition-colors hover:border-green-400"
|
||||
>
|
||||
<i class="fas fa-eye gh-success mb-2 text-2xl"></i>
|
||||
<div class="gh-text text-2xl font-bold">{dataRepo?.repository?.watchers_count}</div>
|
||||
<div class="gh-text-secondary text-sm">Watchers</div>
|
||||
</div>
|
||||
<div
|
||||
class="gh-bg-tertiary stat-card issues rounded-xl border border-[var(--gh-border)] p-4 text-center transition-colors hover:border-red-400"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle gh-danger mb-2 text-2xl"></i>
|
||||
<div class="gh-text text-2xl font-bold">
|
||||
{dataRepo?.repository?.open_issues_count}
|
||||
</div>
|
||||
<div class="gh-text-secondary text-sm">Issues</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Languages -->
|
||||
<div
|
||||
class="gh-bg-secondary rounded-2xl border p-6 sm:p-8"
|
||||
style="border: 1px solid var(--gh-border)"
|
||||
>
|
||||
<div class="mb-6 flex items-center space-x-3">
|
||||
<i class="fas fa-code gh-accent text-xl"></i>
|
||||
<h2 class="gh-text text-xl font-bold">Languages</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each Object.entries(dataRepo.languages) as [language, percent]}
|
||||
<LangBadge lang={language} {percent} mode="bar" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contributors -->
|
||||
<div class="lg:col-span-1">
|
||||
<div
|
||||
class="gh-bg-secondary rounded-2xl border p-6 sm:p-8"
|
||||
style="border: 1px solid var(--gh-border)"
|
||||
>
|
||||
<div class="mb-6 flex items-center space-x-3">
|
||||
<i class="fas fa-users gh-accent text-xl"></i>
|
||||
<h2 class="gh-text text-xl font-bold">Contributors ({dataRepo.contributors.length})</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each dataRepo.contributors as contributor}
|
||||
<div
|
||||
class="gh-bg-tertiary flex items-center space-x-3 rounded-xl border p-3 transition-colors hover:bg-gray-600"
|
||||
style="border: 1px solid var(--gh-border-muted)"
|
||||
>
|
||||
<img
|
||||
src={contributor.avatar_url || '/assets/img/placeholder.jpg'}
|
||||
alt={contributor.login}
|
||||
class="h-10 w-10 rounded-full border-2"
|
||||
style="border-color: var(--gh-border)"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<a
|
||||
href={contributor.html_url}
|
||||
title="See him on GitHub"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<div class="gh-text font-medium">{contributor.login}</div>
|
||||
</a>
|
||||
<div class="gh-text-secondary text-sm">
|
||||
{contributor.contributions} contributions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
@ -1,70 +0,0 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { Game } from './game';
|
||||
|
||||
/** @satisfies {import('./$types').PageServerLoad} */
|
||||
export const load = ({ cookies }) => {
|
||||
const game = new Game(cookies.get('sverdle'));
|
||||
|
||||
return {
|
||||
/**
|
||||
* The player's guessed words so far
|
||||
*/
|
||||
guesses: game.guesses,
|
||||
|
||||
/**
|
||||
* An array of strings like '__x_c' corresponding to the guesses, where 'x' means
|
||||
* an exact match, and 'c' means a close match (right letter, wrong place)
|
||||
*/
|
||||
answers: game.answers,
|
||||
|
||||
/**
|
||||
* The correct answer, revealed if the game is over
|
||||
*/
|
||||
answer: game.answers.length >= 6 ? game.answer : null
|
||||
};
|
||||
};
|
||||
|
||||
/** @satisfies {import('./$types').Actions} */
|
||||
export const actions = {
|
||||
/**
|
||||
* Modify game state in reaction to a keypress. If client-side JavaScript
|
||||
* is available, this will happen in the browser instead of here
|
||||
*/
|
||||
update: async ({ request, cookies }) => {
|
||||
const game = new Game(cookies.get('sverdle'));
|
||||
|
||||
const data = await request.formData();
|
||||
const key = data.get('key');
|
||||
|
||||
const i = game.answers.length;
|
||||
|
||||
if (key === 'backspace') {
|
||||
game.guesses[i] = game.guesses[i].slice(0, -1);
|
||||
} else {
|
||||
game.guesses[i] += key;
|
||||
}
|
||||
|
||||
cookies.set('sverdle', game.toString(), { path: '/' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Modify game state in reaction to a guessed word. This logic always runs on
|
||||
* the server, so that people can't cheat by peeking at the JavaScript
|
||||
*/
|
||||
enter: async ({ request, cookies }) => {
|
||||
const game = new Game(cookies.get('sverdle'));
|
||||
|
||||
const data = await request.formData();
|
||||
const guess = /** @type {string[]} */ (data.getAll('guess'));
|
||||
|
||||
if (!game.enter(guess)) {
|
||||
return fail(400, { badGuess: true });
|
||||
}
|
||||
|
||||
cookies.set('sverdle', game.toString(), { path: '/' });
|
||||
},
|
||||
|
||||
restart: async ({ cookies }) => {
|
||||
cookies.delete('sverdle', { path: '/' });
|
||||
}
|
||||
};
|
@ -1,418 +0,0 @@
|
||||
<script>
|
||||
import { enhance } from '$app/forms';
|
||||
import { confetti } from '@neoconfetti/svelte';
|
||||
|
||||
import { MediaQuery } from 'svelte/reactivity';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {import('./$types').PageData} data
|
||||
* @property {import('./$types').ActionData} form
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {Props}
|
||||
*/
|
||||
let { data, form = $bindable() } = $props();
|
||||
|
||||
/** Whether the user prefers reduced motion */
|
||||
const reducedMotion = new MediaQuery('(prefers-reduced-motion: reduce)');
|
||||
|
||||
/** Whether or not the user has won */
|
||||
let won = $derived(data.answers.at(-1) === 'xxxxx');
|
||||
|
||||
/** The index of the current guess */
|
||||
let i = $derived(won ? -1 : data.answers.length);
|
||||
|
||||
/** The current guess */
|
||||
let currentGuess = $derived(data.guesses[i] || '');
|
||||
|
||||
/** Whether the current guess can be submitted */
|
||||
let submittable = $derived(currentGuess.length === 5);
|
||||
|
||||
const { classnames, description } = $derived.by(() => {
|
||||
/**
|
||||
* A map of classnames for all letters that have been guessed,
|
||||
* used for styling the keyboard
|
||||
* @type {Record<string, 'exact' | 'close' | 'missing'>}
|
||||
*/
|
||||
let classnames = {};
|
||||
/**
|
||||
* A map of descriptions for all letters that have been guessed,
|
||||
* used for adding text for assistive technology (e.g. screen readers)
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
let description = {};
|
||||
data.answers.forEach((answer, i) => {
|
||||
const guess = data.guesses[i];
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const letter = guess[i];
|
||||
if (answer[i] === 'x') {
|
||||
classnames[letter] = 'exact';
|
||||
description[letter] = 'correct';
|
||||
} else if (!classnames[letter]) {
|
||||
classnames[letter] = answer[i] === 'c' ? 'close' : 'missing';
|
||||
description[letter] = answer[i] === 'c' ? 'present' : 'absent';
|
||||
}
|
||||
}
|
||||
});
|
||||
return { classnames, description };
|
||||
});
|
||||
|
||||
/**
|
||||
* Modify the game state without making a trip to the server,
|
||||
* if client-side JavaScript is enabled
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
function update(event) {
|
||||
event.preventDefault();
|
||||
const key = /** @type {HTMLButtonElement} */ (event.target).getAttribute('data-key');
|
||||
|
||||
if (key === 'backspace') {
|
||||
currentGuess = currentGuess.slice(0, -1);
|
||||
if (form?.badGuess) form.badGuess = false;
|
||||
} else if (currentGuess.length < 5) {
|
||||
currentGuess += key;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger form logic in response to a keydown event, so that
|
||||
* desktop users can use the keyboard to play the game
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
function keydown(event) {
|
||||
if (event.metaKey) return;
|
||||
|
||||
if (event.key === 'Enter' && !submittable) return;
|
||||
|
||||
document
|
||||
.querySelector(`[data-key="${event.key}" i]`)
|
||||
?.dispatchEvent(new MouseEvent('click', { cancelable: true, bubbles: true }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={keydown} />
|
||||
|
||||
<svelte:head>
|
||||
<title>Sverdle</title>
|
||||
<meta name="description" content="A Wordle clone written in SvelteKit" />
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="visually-hidden">Sverdle</h1>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action="?/enter"
|
||||
use:enhance={() => {
|
||||
// prevent default callback from resetting the form
|
||||
return ({ update }) => {
|
||||
update({ reset: false });
|
||||
};
|
||||
}}
|
||||
>
|
||||
<a class="how-to-play" href="/sverdle/how-to-play">How to play</a>
|
||||
|
||||
<div class="grid" class:playing={!won} class:bad-guess={form?.badGuess}>
|
||||
{#each Array.from(Array(6).keys()) as row (row)}
|
||||
{@const current = row === i}
|
||||
<h2 class="visually-hidden">Row {row + 1}</h2>
|
||||
<div class="row" class:current>
|
||||
{#each Array.from(Array(5).keys()) as column (column)}
|
||||
{@const guess = current ? currentGuess : data.guesses[row]}
|
||||
{@const answer = data.answers[row]?.[column]}
|
||||
{@const value = guess?.[column] ?? ''}
|
||||
{@const selected = current && column === guess.length}
|
||||
{@const exact = answer === 'x'}
|
||||
{@const close = answer === 'c'}
|
||||
{@const missing = answer === '_'}
|
||||
<div class="letter" class:exact class:close class:missing class:selected>
|
||||
{value}
|
||||
<span class="visually-hidden">
|
||||
{#if exact}
|
||||
(correct)
|
||||
{:else if close}
|
||||
(present)
|
||||
{:else if missing}
|
||||
(absent)
|
||||
{:else}
|
||||
empty
|
||||
{/if}
|
||||
</span>
|
||||
<input name="guess" disabled={!current} type="hidden" {value} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
{#if won || data.answers.length >= 6}
|
||||
{#if !won && data.answer}
|
||||
<p>the answer was "{data.answer}"</p>
|
||||
{/if}
|
||||
<button data-key="enter" class="restart selected" formaction="?/restart">
|
||||
{won ? 'you won :)' : `game over :(`} play again?
|
||||
</button>
|
||||
{:else}
|
||||
<div class="keyboard">
|
||||
<button data-key="enter" class:selected={submittable} disabled={!submittable}>enter</button>
|
||||
|
||||
<button
|
||||
onclick={update}
|
||||
data-key="backspace"
|
||||
formaction="?/update"
|
||||
name="key"
|
||||
value="backspace"
|
||||
>
|
||||
back
|
||||
</button>
|
||||
|
||||
{#each ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'] as row (row)}
|
||||
<div class="row">
|
||||
{#each row as letter, index (index)}
|
||||
<button
|
||||
onclick={update}
|
||||
data-key={letter}
|
||||
class={classnames[letter]}
|
||||
disabled={submittable}
|
||||
formaction="?/update"
|
||||
name="key"
|
||||
value={letter}
|
||||
aria-label="{letter} {description[letter] || ''}"
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if won}
|
||||
<div
|
||||
style="position: absolute; left: 50%; top: 30%"
|
||||
use:confetti={{
|
||||
particleCount: reducedMotion.current ? 0 : undefined,
|
||||
force: 0.7,
|
||||
stageWidth: window.innerWidth,
|
||||
stageHeight: window.innerHeight,
|
||||
colors: ['#ff3e00', '#40b3ff', '#676778']
|
||||
}}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
form {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.how-to-play {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.how-to-play::before {
|
||||
content: 'i';
|
||||
display: inline-block;
|
||||
font-size: 0.8em;
|
||||
font-weight: 900;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
padding: 0.2em;
|
||||
line-height: 1;
|
||||
border: 1.5px solid var(--color-text);
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
margin: 0 0.5em 0 0;
|
||||
position: relative;
|
||||
top: -0.05em;
|
||||
}
|
||||
|
||||
.grid {
|
||||
--width: min(100vw, 40vh, 380px);
|
||||
max-width: var(--width);
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.grid .row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-gap: 0.2rem;
|
||||
margin: 0 0 0.2rem 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.grid.bad-guess .row.current {
|
||||
animation: wiggle 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
.grid.playing .row.current {
|
||||
filter: drop-shadow(3px 3px 10px var(--color-bg-0));
|
||||
}
|
||||
|
||||
.letter {
|
||||
aspect-ratio: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
text-transform: lowercase;
|
||||
border: none;
|
||||
font-size: calc(0.08 * var(--width));
|
||||
border-radius: 2px;
|
||||
background: white;
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.letter.missing {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.letter.exact {
|
||||
background: var(--color-theme-2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.letter.close {
|
||||
border: 2px solid var(--color-theme-2);
|
||||
}
|
||||
|
||||
.selected {
|
||||
outline: 2px solid var(--color-theme-1);
|
||||
}
|
||||
|
||||
.controls {
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
height: min(18vh, 10rem);
|
||||
}
|
||||
|
||||
.keyboard {
|
||||
--gap: 0.2rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.keyboard .row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.keyboard button,
|
||||
.keyboard button:disabled {
|
||||
--size: min(8vw, 4vh, 40px);
|
||||
background-color: white;
|
||||
color: black;
|
||||
width: var(--size);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
font-size: calc(var(--size) * 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.keyboard button.exact {
|
||||
background: var(--color-theme-2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.keyboard button.missing {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.keyboard button.close {
|
||||
border: 2px solid var(--color-theme-2);
|
||||
}
|
||||
|
||||
.keyboard button:focus {
|
||||
background: var(--color-theme-1);
|
||||
color: white;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.keyboard button[data-key='enter'],
|
||||
.keyboard button[data-key='backspace'] {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: calc(1.5 * var(--size));
|
||||
height: calc(1 / 3 * (100% - 2 * var(--gap)));
|
||||
text-transform: uppercase;
|
||||
font-size: calc(0.3 * var(--size));
|
||||
padding-top: calc(0.15 * var(--size));
|
||||
}
|
||||
|
||||
.keyboard button[data-key='enter'] {
|
||||
right: calc(50% + 3.5 * var(--size) + 0.8rem);
|
||||
}
|
||||
|
||||
.keyboard button[data-key='backspace'] {
|
||||
left: calc(50% + 3.5 * var(--size) + 0.8rem);
|
||||
}
|
||||
|
||||
.keyboard button[data-key='enter']:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.restart {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 2px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.restart:focus,
|
||||
.restart:hover {
|
||||
background: var(--color-theme-1);
|
||||
color: white;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
30% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
70% {
|
||||
transform: translateX(+4px);
|
||||
}
|
||||
90% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,72 +0,0 @@
|
||||
import { words, allowed } from './words.server';
|
||||
|
||||
export class Game {
|
||||
/**
|
||||
* Create a game object from the player's cookie, or initialise a new game
|
||||
* @param {string | undefined} serialized
|
||||
*/
|
||||
constructor(serialized = undefined) {
|
||||
if (serialized) {
|
||||
const [index, guesses, answers] = serialized.split('-');
|
||||
|
||||
this.index = +index;
|
||||
this.guesses = guesses ? guesses.split(' ') : [];
|
||||
this.answers = answers ? answers.split(' ') : [];
|
||||
} else {
|
||||
this.index = Math.floor(Math.random() * words.length);
|
||||
this.guesses = ['', '', '', '', '', ''];
|
||||
this.answers = /** @type {string[]} */ ([]);
|
||||
}
|
||||
|
||||
this.answer = words[this.index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update game state based on a guess of a five-letter word. Returns
|
||||
* true if the guess was valid, false otherwise
|
||||
* @param {string[]} letters
|
||||
*/
|
||||
enter(letters) {
|
||||
const word = letters.join('');
|
||||
const valid = allowed.has(word);
|
||||
|
||||
if (!valid) return false;
|
||||
|
||||
this.guesses[this.answers.length] = word;
|
||||
|
||||
const available = Array.from(this.answer);
|
||||
const answer = Array(5).fill('_');
|
||||
|
||||
// first, find exact matches
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
if (letters[i] === available[i]) {
|
||||
answer[i] = 'x';
|
||||
available[i] = ' ';
|
||||
}
|
||||
}
|
||||
|
||||
// then find close matches (this has to happen
|
||||
// in a second step, otherwise an early close
|
||||
// match can prevent a later exact match)
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
if (answer[i] === '_') {
|
||||
const index = available.indexOf(letters[i]);
|
||||
if (index !== -1) {
|
||||
answer[i] = 'c';
|
||||
available[index] = ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.answers.push(answer.join(''));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize game state so it can be set as a cookie
|
||||
*/
|
||||
toString() {
|
||||
return `${this.index}-${this.guesses.join(' ')}-${this.answers.join(' ')}`;
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
// we don't need any JS on this page, though we'll load
|
||||
// it in dev so that we get hot module replacement
|
||||
export const csr = dev;
|
||||
|
||||
// since there's no dynamic data here, we can prerender
|
||||
// it so that it gets served as a static asset in production
|
||||
export const prerender = true;
|
@ -1,95 +0,0 @@
|
||||
<svelte:head>
|
||||
<title>How to play Sverdle</title>
|
||||
<meta name="description" content="How to play Sverdle" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="text-column">
|
||||
<h1>How to play Sverdle</h1>
|
||||
|
||||
<p>
|
||||
Sverdle is a clone of <a href="https://www.nytimes.com/games/wordle/index.html">Wordle</a>, the
|
||||
word guessing game. To play, enter a five-letter English word. For example:
|
||||
</p>
|
||||
|
||||
<div class="example">
|
||||
<span class="close">r</span>
|
||||
<span class="missing">i</span>
|
||||
<span class="close">t</span>
|
||||
<span class="missing">z</span>
|
||||
<span class="exact">y</span>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
The <span class="exact">y</span> is in the right place. <span class="close">r</span> and
|
||||
<span class="close">t</span>
|
||||
are the right letters, but in the wrong place. The other letters are wrong, and can be discarded.
|
||||
Let's make another guess:
|
||||
</p>
|
||||
|
||||
<div class="example">
|
||||
<span class="exact">p</span>
|
||||
<span class="exact">a</span>
|
||||
<span class="exact">r</span>
|
||||
<span class="exact">t</span>
|
||||
<span class="exact">y</span>
|
||||
</div>
|
||||
|
||||
<p>This time we guessed right! You have <strong>six</strong> guesses to get the word.</p>
|
||||
|
||||
<p>
|
||||
Unlike the original Wordle, Sverdle runs on the server instead of in the browser, making it
|
||||
impossible to cheat. It uses <code><form></code> and cookies to submit data, meaning you can
|
||||
even play with JavaScript disabled!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
span {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 0.8em;
|
||||
width: 2.4em;
|
||||
height: 2.4em;
|
||||
background-color: white;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
border-width: 2px;
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.missing {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.close {
|
||||
border-style: solid;
|
||||
border-color: var(--color-theme-2);
|
||||
}
|
||||
|
||||
.exact {
|
||||
background: var(--color-theme-2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.example {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin: 1rem 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.example span {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
p span {
|
||||
position: relative;
|
||||
border-width: 1px;
|
||||
border-radius: 1px;
|
||||
font-size: 0.4em;
|
||||
transform: scale(2) translate(0, -10%);
|
||||
margin: 0 1em;
|
||||
}
|
||||
</style>
|
17
static/assets/css/noscript.css
Normal file
@ -0,0 +1,17 @@
|
||||
.noscript-message {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.noscript-message h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.noscript-message p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.noscript-message a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
BIN
static/assets/favicon/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
static/assets/favicon/favicon-96x96.png
Normal file
After Width: | Height: | Size: 4.6 KiB |
BIN
static/assets/favicon/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
3
static/assets/favicon/favicon.svg
Normal file
After Width: | Height: | Size: 109 KiB |
21
static/assets/favicon/site.webmanifest
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/assets/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000",
|
||||
"display": "standalone"
|
||||
}
|
BIN
static/assets/favicon/web-app-manifest-192x192.png
Normal file
After Width: | Height: | Size: 5.6 KiB |
BIN
static/assets/favicon/web-app-manifest-512x512.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
static/assets/img/placeholder.jpg
Normal file
After Width: | Height: | Size: 55 KiB |
BIN
static/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 1.5 KiB |
@ -1,5 +1,20 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
const config = { kit: { adapter: adapter() } };
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
// default options are shown. On some platforms
|
||||
// these options are set automatically — see below
|
||||
pages: 'build',
|
||||
assets: 'build/assets',
|
||||
fallback: '404.html',
|
||||
precompress: true,
|
||||
strict: true
|
||||
}),
|
||||
paths: {
|
||||
base: process.argv.includes('dev') ? '' : process.env.BASE_PATH
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
32
tailwind.config.js
Normal file
@ -0,0 +1,32 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
import colors from './src/assets/github-colors/colors.json';
|
||||
|
||||
export default {
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/**/*.{svelte,js,ts,jsx,tsx}', './*.{js,ts,jsx,tsx,mdx,html}'],
|
||||
safelist: Object.entries(colors).flatMap(([lang, data]) => {
|
||||
const hex = data.color?.replace('#', '');
|
||||
return [`bg-[#${hex}/20]`, `text-[#${hex}]`];
|
||||
}),
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'gh-bg': '#0d1117',
|
||||
'gh-bg-secondary': '#161b22',
|
||||
'gh-bg-tertiary': '#21262d',
|
||||
'gh-border': '#30363d',
|
||||
'gh-border-muted': '#21262d',
|
||||
'gh-text': '#f0f6fc',
|
||||
'gh-text-secondary': '#7d8590',
|
||||
'gh-text-muted': '#656d76',
|
||||
'gh-accent': '#58a6ff',
|
||||
'gh-accent-emphasis': '#1f6feb',
|
||||
'gh-success': '#3fb950',
|
||||
'gh-attention': '#d29922',
|
||||
'gh-severe': '#db6d28',
|
||||
'gh-danger': '#f85149'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
@ -2,6 +2,25 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
import { createHtmlPlugin } from 'vite-plugin-html';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
function getPkgVersion(pkgPath) {
|
||||
return JSON.parse(readFileSync(resolve(pkgPath), 'utf-8')).version;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
createHtmlPlugin({
|
||||
inject: {
|
||||
data: {
|
||||
viteVersion: getPkgVersion('./node_modules/vite/package.json'),
|
||||
sveltekitVersion: getPkgVersion('./node_modules/@sveltejs/kit/package.json')
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
|