Compare commits

..

10 Commits

19 changed files with 744 additions and 41 deletions

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json .
COPY package-lock.json .
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "build"]

243
package-lock.json generated
View File

@@ -11,7 +11,7 @@
"pocketbase": "^0.26.3" "pocketbase": "^0.26.3"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.47.1", "@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
@@ -21,6 +21,7 @@
"svelte": "^5.41.0", "svelte": "^5.41.0",
"svelte-check": "^4.3.3", "svelte-check": "^4.3.3",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tailwindcss-motion": "^1.1.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.1.10" "vite": "^7.1.10"
} }
@@ -524,6 +525,112 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/plugin-commonjs": {
"version": "28.0.9",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz",
"integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"commondir": "^1.0.1",
"estree-walker": "^2.0.2",
"fdir": "^6.2.0",
"is-reference": "1.2.1",
"magic-string": "^0.30.3",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=16.0.0 || 14 >= 14.17"
},
"peerDependencies": {
"rollup": "^2.68.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-commonjs/node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@rollup/plugin-json": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
"integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.78.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.2", "version": "4.53.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz",
@@ -849,14 +956,20 @@
"acorn": "^8.9.0" "acorn": "^8.9.0"
} }
}, },
"node_modules/@sveltejs/adapter-auto": { "node_modules/@sveltejs/adapter-node": {
"version": "7.0.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.0.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.4.0.tgz",
"integrity": "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw==", "integrity": "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.0",
"rollup": "^4.9.5"
},
"peerDependencies": { "peerDependencies": {
"@sveltejs/kit": "^2.0.0" "@sveltejs/kit": "^2.4.0"
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
@@ -1223,6 +1336,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1282,6 +1402,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -1410,6 +1537,13 @@
"@jridgewell/sourcemap-codec": "^1.4.15" "@jridgewell/sourcemap-codec": "^1.4.15"
} }
}, },
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1443,6 +1577,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1450,6 +1594,42 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
"dev": true,
"license": "MIT"
},
"node_modules/is-reference": { "node_modules/is-reference": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -1804,6 +1984,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1979,6 +2166,27 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.53.2", "version": "4.53.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz",
@@ -2066,6 +2274,19 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.43.8", "version": "5.43.8",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.8.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.8.tgz",
@@ -2123,6 +2344,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tailwindcss-motion": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tailwindcss-motion/-/tailwindcss-motion-1.1.1.tgz",
"integrity": "sha512-CeeQAc5o31BuEPMyWdq/786X7QWNeifa+8khfu74Fs8lGkgEwjNYv6dGv+lRFS8FWXV5dp7F3AU9JjBXjiaQfw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",

View File

@@ -14,7 +14,7 @@
"lint": "prettier --check ." "lint": "prettier --check ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.47.1", "@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
@@ -24,6 +24,7 @@
"svelte": "^5.41.0", "svelte": "^5.41.0",
"svelte-check": "^4.3.3", "svelte-check": "^4.3.3",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tailwindcss-motion": "^1.1.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.1.10" "vite": "^7.1.10"
}, },

View File

@@ -1,4 +1,5 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin 'tailwindcss-motion';
body { body {
@apply bg-gray-50 font-sans text-gray-800 antialiased; @apply bg-gray-50 font-sans text-gray-800 antialiased;

View File

@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
const variants = { const variants = {
primary: primary:
'px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 rounded-xl transition-all duration-200 ease-in-out disabled:opacity-70 disabled:cursor-not-allowed', 'px-4 py-2 text-sm font-medium text-white bg-emerald-800 hover:bg-emerald-900 rounded-xl transition-all duration-200 ease-in-out disabled:opacity-70 disabled:cursor-not-allowed',
secondary: secondary:
'px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-100 rounded-xl transition-all duration-200 ease-in-out', 'px-4 py-2 text-sm font-medium text-gray-100 bg-gray-600 border border-gray-300 hover:bg-gray-500 rounded-xl transition-all duration-200 ease-in-out',
danger: danger:
'px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-xl transition-all duration-200 ease-in-out disabled:opacity-70 disabled:cursor-not-allowed' 'px-4 py-2 text-sm font-medium text-white bg-red-800 hover:bg-red-900 rounded-xl transition-all duration-200 ease-in-out disabled:opacity-70 disabled:cursor-not-allowed'
}; };
type Props = { type Props = {
@@ -18,14 +19,17 @@
href?: string; href?: string;
}; };
let { children, onClick, disabled, variant = 'primary', href }: Props = $props(); let { children, onClick, disabled, variant = 'primary', href }: Props = $props();
const isActive = page.params.year === href?.split('/').pop();
$inspect(isActive, 'isActive', page.params.year, href);
</script> </script>
{#if href} {#if href}
<a {href} class={variants[variant]}> <a {href} class={variants[variant]} class:!bg-gray-900={isActive}>
{@render children()} {@render children()}
</a> </a>
{:else} {:else}
<button class={variants[variant]} onclick={onClick} {disabled}> <button style:cursor="pointer" class={variants[variant]} onclick={onClick} {disabled}>
{@render children()} {@render children()}
</button> </button>
{/if} {/if}

View File

@@ -9,11 +9,11 @@
</script> </script>
<div <div
class="rounded-2xl bg-gray-200 p-4 shadow-sm transition-all duration-200 ease-in-out hover:shadow-md md:p-6" class="rounded-2xl bg-gray-500 p-4 shadow-sm transition-all duration-200 ease-in-out hover:shadow-md md:p-6"
> >
<div class="mb-1 text-sm text-gray-600">{title}</div> <div class="mb-1 text-sm text-gray-200">{title}</div>
<div class="text-3xl font-bold text-gray-800">{value}</div> <div class="text-3xl font-bold text-gray-100">{value}</div>
{#if description} {#if description}
<div class="mt-2 text-xs text-gray-500">{description}</div> <div class="mt-2 text-xs text-gray-200">{description}</div>
{/if} {/if}
</div> </div>

View File

@@ -1,10 +1,17 @@
<script lang="ts"> <script lang="ts">
import { refreshAll } from '$app/navigation';
import { formatCurrency } from '$lib/helpers/formatCurrency'; import { formatCurrency } from '$lib/helpers/formatCurrency';
import { DB } from '$lib/integrations/db';
import Badge from '../atoms/Badge.svelte'; import Badge from '../atoms/Badge.svelte';
import Heading from '../atoms/Heading.svelte'; import Heading from '../atoms/Heading.svelte';
import { fly } from 'svelte/transition'; import GiftModal from './GiftModal.svelte';
let gift: Gift = $props(); type Props = {
editable?: boolean;
};
let gift: Gift & Props = $props();
let isEditModal = $state(false);
const bgByStatus = { const bgByStatus = {
planned: 'bg-gray-600', planned: 'bg-gray-600',
@@ -17,8 +24,8 @@
<div <div
class={`my-6 rounded-2xl p-4 shadow-sm transition-all duration-200 ease-in-out ${bgByStatus[gift.status]}`} class={`my-6 rounded-2xl p-4 shadow-sm transition-all duration-200 ease-in-out ${bgByStatus[gift.status]}`}
in:fly={{ x: 100 }} class:cursor-pointer={!!gift.editable}
out:fly={{ x: -100 }} onclick={() => (isEditModal = true)}
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
@@ -26,6 +33,19 @@
<Heading size="small" spacing="none"> <Heading size="small" spacing="none">
{gift.title} {gift.title}
</Heading> </Heading>
{#if gift.editable}
<p
class="cursor-pointer text-xs text-red-400"
onclick={(e) => {
e.stopPropagation();
DB.deleteGift(gift.id).finally(() => {
refreshAll();
});
}}
>
Usuń
</p>
{/if}
</div> </div>
{#if gift.link} {#if gift.link}
@@ -38,7 +58,7 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2 text-white">
<span>Koszt: {formatCurrency(gift.cost)}</span> <span>Koszt: {formatCurrency(gift.cost)}</span>
</div> </div>
{#if gift.description} {#if gift.description}
@@ -46,3 +66,18 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if isEditModal}
<GiftModal
title="Edytuj prezent"
personId={gift.person}
editGift={gift}
isOpen={isEditModal}
onClose={() => (isEditModal = false)}
onSave={async (data) => {
await DB.updateGift(gift.id, data);
refreshAll();
isEditModal = false;
}}
/>
{/if}

View File

@@ -0,0 +1,149 @@
<script lang="ts">
import type { ComponentProps } from 'svelte';
import type { DB } from '$lib/integrations/db';
import Modal from '../atoms/Modal.svelte';
import Button from '../atoms/Button.svelte';
import { page } from '$app/state';
import { formatStatus } from '$lib/helpers/formatStatus';
type CreateGift = Parameters<typeof DB.createGift>[0];
type Props = {
onSave: (data: CreateGift) => Promise<void>;
editGift?: Gift;
personId: string;
} & Omit<ComponentProps<typeof Modal>, 'children'>;
let { onSave, isOpen, onClose, editGift, personId }: Props = $props();
let gift = $state<Gift>(editGift ? { ...editGift } : ({} as Gift));
let isLoading = $state(false);
if (!gift?.title) {
gift.title = '';
gift.description = '';
gift.link = '';
gift.cost = 0;
gift.imageUrl = '';
gift.status = 'planned';
gift.year = page.data.year.id;
gift.person = personId;
}
const handleSubmit = async (e: Event) => {
e.preventDefault();
isLoading = true;
try {
await onSave(gift);
} catch (error) {
console.error(error);
} finally {
isLoading = false;
}
};
</script>
<Modal {isOpen} {onClose} title={gift ? 'Edytuj prezent' : 'Dodaj nowy prezent'}>
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="title" class="mb-2 block text-sm font-medium text-gray-700"> Tytuł * </label>
<input
id="title"
type="text"
class="w-full rounded-xl border border-gray-300 px-4 py-2 transition-all outline-none focus:border-transparent focus:ring-2 focus:ring-blue-500"
required
disabled={isLoading}
bind:value={gift.title}
/>
</div>
<div>
<label for="description" class="mb-2 block text-sm font-medium text-gray-700"> Opis </label>
<textarea
id="description"
rows={3}
class="w-full resize-none rounded-xl border border-gray-300 px-4 py-2 transition-all outline-none focus:border-transparent focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
bind:value={gift.description}
></textarea>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="cost" class="mb-2 block text-sm font-medium text-gray-700">
Koszt (PLN) *
</label>
<input
id="cost"
type="number"
step="0.01"
min="0"
bind:value={gift.cost}
class="w-full rounded-xl border border-gray-300 px-4 py-2 transition-all outline-none focus:border-transparent focus:ring-2 focus:ring-blue-500"
required
disabled={isLoading}
/>
</div>
<div>
<label for="status" class="mb-2 block text-sm font-medium text-gray-700"> Status * </label>
<select
id="status"
bind:value={gift.status}
class="w-full rounded-xl border border-gray-300 px-4 py-2 transition-all outline-none focus:border-transparent focus:ring-2 focus:ring-blue-500"
required
disabled={isLoading}
>
<option value="planned">{formatStatus('planned')} </option>
<option value="decided">{formatStatus('decided')}</option>
<option value="bought">{formatStatus('bought')}</option>
<option value="ready">{formatStatus('ready')}</option>
<option value="wrapped">{formatStatus('wrapped')}</option>
</select>
</div>
</div>
<div>
<label for="link" class="mb-2 block text-sm font-medium text-gray-700"> Link </label>
<textarea
id="linki"
bind:value={gift.link}
rows={3}
class="w-full resize-none rounded-xl border border-gray-300 px-4 py-2 transition-all outline-none focus:border-transparent focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
></textarea>
</div>
<div>
<label for="ceneoId" class="mb-2 block text-sm font-medium text-gray-700"> ID Ceneo </label>
<input
id="ceneoId"
type="text"
bind:value={gift.ceneo_id}
class="w-full rounded-xl border border-gray-300 px-4 py-2 transition-all outline-none focus:border-transparent focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
<p class="mt-2 text-xs text-gray-500">
Jeśli produkt nie został jeszcze kupiony i zostanie podany jego ID na ceneo, bot będzie
obserwował ceny i w razie zmian będzie informował o tym na discord.
</p>
</div>
<div>
<label for="imageUrl" class="mb-2 block text-sm font-medium text-gray-700">
URL obrazu
</label>
<input
id="imageUrl"
type="url"
bind:value={gift.imageUrl}
class="w-full rounded-xl border border-gray-300 px-4 py-2 transition-all outline-none focus:border-transparent focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
</div>
</form>
{#snippet footer()}
<div class="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose} disabled={isLoading}>Anuluj</Button>
<Button variant="primary" onClick={handleSubmit} disabled={isLoading || !gift?.title?.trim()}>
{isLoading ? 'Zapisywanie...' : 'Zapisz'}
</Button>
</div>
{/snippet}
</Modal>

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { refreshAll } from '$app/navigation';
import { formatCurrency } from '$lib/helpers/formatCurrency';
import { DB } from '$lib/integrations/db';
import Button from '../atoms/Button.svelte';
import Heading from '../atoms/Heading.svelte';
import GiftCard from './GiftCard.svelte';
import GiftModal from './GiftModal.svelte';
import PersonModal from './PersonModal.svelte';
let person: Person = $props();
const gifts = $derived(person.expand.gifts);
const totalCost = $derived(gifts?.reduce((acc, gift) => acc + gift.cost, 0) || 0);
let editPersonModal = $state(false);
let addGiftModal = $state(false);
</script>
<div
class="mt-6 rounded-2xl bg-gray-300 p-4 text-gray-700 shadow-sm transition-all duration-200 ease-in-out hover:shadow-md md:p-6"
>
<div
class="mb-6 flex flex-col items-center gap-4 border-b border-gray-700 pb-4 sm:justify-between"
>
<Heading size="large" spacing="none">
<span class="text-gray-700">
{person.name}
</span>
</Heading>
<div class="flex flex-row items-center justify-center gap-2">
<Button variant="primary" onClick={() => (editPersonModal = true)}>Edytuj</Button>
<Button
variant="danger"
onClick={() => {
DB.deletePerson(person.id).finally(() => {
refreshAll();
});
}}>Usuń</Button
>
</div>
<PersonModal
isOpen={editPersonModal}
onClose={() => (editPersonModal = false)}
editPerson={person}
title="Edytuj osobę"
onSave={async (data) => {
await DB.updatePerson(person.id, data);
refreshAll();
editPersonModal = false;
}}
/>
{#if person.notes}
<p class="text-gray-600">{person.notes}</p>
{/if}
</div>
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500">
<span>Ilość prezentów: {gifts?.length || 0}</span>
<span></span>
<span>Koszt: {formatCurrency(totalCost)}</span>
</div>
<div class="mt-8 mb-4 flex items-start justify-between gap-4">
<Heading size="medium" spacing="none">
<span class="text-gray-700">
Prezenty <br />
</span>
</Heading>
<Button variant="secondary" onClick={() => (addGiftModal = true)}>+</Button>
<!-- <GiftCardAdd person={person} /> -->
<GiftModal
title="Dodaj nowy prezent"
isOpen={addGiftModal}
onClose={() => (addGiftModal = false)}
onSave={async (data) => {
await DB.createGift(data);
refreshAll();
addGiftModal = false;
}}
personId={person.id}
/>
</div>
{#each gifts as gift (gift.id)}
<GiftCard {...gift} editable />
{/each}
</div>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import type { ComponentProps } from 'svelte';
import type { DB } from '$lib/integrations/db';
import Modal from '../atoms/Modal.svelte';
import Button from '../atoms/Button.svelte';
import { page } from '$app/state';
type CreatePerson = Parameters<typeof DB.createPerson>[0];
type Props = {
onSave: (data: CreatePerson) => Promise<void>;
editPerson?: Person;
} & Omit<ComponentProps<typeof Modal>, 'children'>;
let { onSave, isOpen, onClose, editPerson }: Props = $props();
let person = $state<Person>(editPerson ? { ...editPerson } : ({} as Person));
let isLoading = $state(false);
if (!person?.name) {
person.name = '';
person.notes = '';
person.years = [page.params.year || new Date().getFullYear().toString()];
}
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!person) return;
isLoading = true;
try {
await onSave(person);
} catch (error) {
console.error(error);
} finally {
isLoading = false;
}
};
</script>
<Modal {isOpen} {onClose} title={person ? 'Edytuj osobę' : 'Dodaj nową osobę'}>
<form onsubmit={handleSubmit} class="space-y-4">
<div>
<label for="name" class="mb-2 block text-sm font-medium text-gray-700">
Imię i nazwisko *
</label>
<input
id="name"
type="text"
class="w-full rounded-xl border border-gray-300 px-4 py-2 transition-all outline-none focus:border-transparent focus:ring-2 focus:ring-blue-500"
required
disabled={isLoading}
bind:value={person.name}
/>
</div>
<div>
<label for="notes" class="mb-2 block text-sm font-medium text-gray-700"> Notatki </label>
<textarea
id="notes"
rows={4}
class="w-full resize-none rounded-xl border border-gray-300 px-4 py-2 transition-all outline-none focus:border-transparent focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
bind:value={person.notes}
></textarea>
</div>
</form>
{#snippet footer()}
<div class="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose} disabled={isLoading}>Anuluj</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isLoading || !person?.name?.trim()}
>
{isLoading ? 'Zapisywanie...' : 'Zapisz'}
</Button>
</div>
{/snippet}
</Modal>

View File

@@ -12,7 +12,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button variant="secondary" href="/">Prezenty tego roku</Button> <Button variant="secondary" href="/">Prezenty tego roku</Button>
{#each years as year} {#each years as year}
<Button href={`/year/${year.year}`} variant="secondary"> <Button href={`/rok/${year.year}`} variant="secondary">
{year.year} {year.year}
</Button> </Button>
{/each} {/each}

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import GiftCard from '../molecules/GiftCard.svelte'; import GiftCard from '../molecules/GiftCard.svelte';
import { cubicInOut } from 'svelte/easing'; import { fly } from 'svelte/transition';
type Props = { type Props = {
gifts: Gift[]; gifts: Gift[];
@@ -12,29 +12,43 @@
let sort = $state<keyof Pick<Gift, 'title' | 'cost' | 'person' | 'status'>>('title'); let sort = $state<keyof Pick<Gift, 'title' | 'cost' | 'person' | 'status'>>('title');
let sortedGifts = $derived.by(() => { let sortedGifts = $derived.by(() => {
let newSortedGifts = [...gifts]; let out = [...gifts];
if (sort === 'cost') {
newSortedGifts.sort((a, b) => a.cost - b.cost); switch (sort) {
} else if (sort === 'person') { case 'title':
newSortedGifts.sort((a, b) => a.expand.person.name.localeCompare(b.expand.person.name)); out.sort((a, b) => a.title.localeCompare(b.title));
} else if (sort === 'status') { break;
newSortedGifts.sort((a, b) => a.status.localeCompare(b.status)); case 'cost':
out.sort((a, b) => a.cost - b.cost);
break;
case 'person':
out.sort((a, b) => a.expand.person.name.localeCompare(b.expand.person.name));
break;
case 'status':
out.sort((a, b) => a.status.localeCompare(b.status));
break;
} }
return newSortedGifts;
return out;
}); });
let filteredGifts = $derived.by(() => { let filteredGifts = $derived.by(() => {
if (filter === 'all') return sortedGifts; let out = [...sortedGifts];
return sortedGifts.filter((gift) => gift.status === filter);
if (filter !== 'all') {
out = out.filter((gift) => gift.status === filter);
}
return out;
}); });
let renderGifts = $derived(filteredGifts); let renderedGifts = $derived(filteredGifts);
</script> </script>
<section> <section>
<div class="flex flex-row items-center justify-between gap-4"> <div class="flex flex-row items-center justify-between gap-4">
<select <select
class="rounded-xl border border-gray-300 px-4 py-2 transition-all outline-none focus:border-transparent focus:ring-2 focus:ring-blue-500" class="ml-auto rounded-xl border border-gray-300 px-4 py-2 transition-all outline-none focus:border-transparent focus:ring-2 focus:ring-blue-500"
bind:value={filter} bind:value={filter}
> >
<option value="all">Wszystkie</option> <option value="all">Wszystkie</option>
@@ -54,9 +68,13 @@
</select> </select>
</div> </div>
{#each renderGifts as gift (gift.id)} {#key filter}
<div animate:flip={{ duration: 400, easing: cubicInOut }}> <div in:fly={{ x: 100, delay: 250, duration: 200 }} out:fly={{ x: -100, duration: 200 }}>
<GiftCard {...gift} /> {#each renderedGifts as gift (gift.id)}
<div animate:flip={{ duration: 200 }}>
<GiftCard {...gift} />
</div>
{/each}
</div> </div>
{/each} {/key}
</section> </section>

View File

@@ -0,0 +1,22 @@
<script>
import { refreshAll } from '$app/navigation';
import { DB } from '$lib/integrations/db';
import ActionCard from '../molecules/ActionCard.svelte';
import PersonModal from '../molecules/PersonModal.svelte';
let isOpen = $state(false);
</script>
<div class="my-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<ActionCard title="Dodaj nową osobę" onClick={() => (isOpen = true)} />
</div>
<PersonModal
{isOpen}
onClose={() => (isOpen = false)}
title="Dodaj nową osobę"
onSave={async (data) => {
await DB.createPerson(data);
refreshAll();
}}
/>

View File

@@ -0,0 +1,14 @@
export const formatStatus = (status: Gift['status']) => {
switch (status) {
case 'planned':
return 'Planowane';
case 'decided':
return 'Do kupienia';
case 'bought':
return 'Kupione';
case 'ready':
return 'Do spakowania';
case 'wrapped':
return 'Gotowe';
}
};

View File

@@ -18,7 +18,8 @@ export const DB = {
return await pb.collection('gifts_person').getFirstListItem(`name = "${name}"`); return await pb.collection('gifts_person').getFirstListItem(`name = "${name}"`);
}, },
createPerson: async (data: Pick<DB.Person, 'name' | 'notes' | 'years'>): Promise<DB.Person> => { createPerson: async (data: Pick<DB.Person, 'name' | 'notes' | 'years'>): Promise<DB.Person> => {
return await pb.collection('gifts_person').create({ ...data }); const year = await pb.collection('gifts_year').getFirstListItem(`year = ${data.years[0]}`);
return await pb.collection('gifts_person').create({ ...data, years: [year.id] });
}, },
updatePerson: async (id: string, data: Pick<DB.Person, 'name' | 'notes'>): Promise<Person> => { updatePerson: async (id: string, data: Pick<DB.Person, 'name' | 'notes'>): Promise<Person> => {
return await pb.collection('gifts_person').update(id, data); return await pb.collection('gifts_person').update(id, data);

View File

@@ -0,0 +1,5 @@
import { DB } from '$lib/integrations/db';
export const LoaderPersons = (yearId: string) => {
return () => DB.getPersons(yearId);
};

View File

@@ -0,0 +1,19 @@
import { orchestrateLoaders } from '$lib/loaders';
import { LoaderPersons } from '$lib/loaders/LoaderPerson';
import { LoaderYear } from '$lib/loaders/LoaderYear';
import { LoaderYears } from '$lib/loaders/LoaderYear';
export const load = async ({ params }: { params: { year: string } }) => {
const [yearData, yearsData] = await orchestrateLoaders([
LoaderYear(Number(params.year)),
LoaderYears()
]);
const personsData = await LoaderPersons(yearData.id)();
return {
year: yearData,
years: yearsData,
persons: personsData
};
};

View File

@@ -0,0 +1,17 @@
<script>
import PersonCard from '$lib/components/molecules/PersonCard.svelte';
import YearNav from '$lib/components/molecules/YearNav.svelte';
import YearControls from '$lib/components/organisms/YearControls.svelte';
import YearOverview from '$lib/components/organisms/YearOverview.svelte';
let { data } = $props();
</script>
<YearOverview data={data.year} />
<YearNav years={data.years} />
<YearControls />
<section class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each data.persons as person}
<PersonCard {...person} />
{/each}
</section>

View File

@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
@@ -18,6 +18,7 @@ const config = {
warningFilter: (warning) => { warningFilter: (warning) => {
const ignoreCodes = [ const ignoreCodes = [
'a11y_no_static_element_interactions', 'a11y_no_static_element_interactions',
'a11y_no_noninteractive_element_interactions',
'a11y_click_events_have_key_events' 'a11y_click_events_have_key_events'
]; ];
return !ignoreCodes.includes(warning.code); return !ignoreCodes.includes(warning.code);