Compare commits

...
Sign in to create a new pull request.

45 commits

Author SHA1 Message Date
d19e802c8c add: health check
health check for docker
2025-07-15 13:38:50 +02:00
a27af9aefb add: compose file
docker compose file
2025-07-15 08:22:48 +02:00
eeed72270e Merge pull request 'add: "ordentliche" Datenschutzerklärung' () from legal-text-update into main
Reviewed-on: 
Reviewed-by: Fiona Lena Urban <fiona@webfussel.de>
2025-07-10 09:44:55 +02:00
8783b8467a add: "ordentliche" Datenschutzerklärung 2025-07-10 09:13:55 +02:00
f5fa5b2971 fix: small wording issues
Fix wording issues for consistency
2025-05-23 10:32:39 +02:00
d8c28ceea1 fix: dialog max width on desktop
Add clamp for dialogs for better look on desktop
2025-05-23 10:14:07 +02:00
eec6175e13 fix: dialog max width on desktop
Add max width for dialogs for better look on desktop
2025-05-23 10:10:54 +02:00
d7b624df4d fix: Calculator fixes
Add mobile/desktop fixes to calculator
2025-05-23 09:20:59 +02:00
d71e59b9c0 add: OpenGraph image, timeline card state
Added three states to timeline card, added open graph image and description
2025-05-23 08:58:14 +02:00
f75d66a0d0 Merge pull request 'add: desktop-background + fix: desktop-layout' () from desktop-fixes into main
Reviewed-on: 
2025-05-23 08:26:41 +02:00
0ccfe985ae add: small fix for flex
Add flex attribute
2025-05-23 08:26:03 +02:00
2d31b019e9 add: desktop-background + fix: desktop-layout 2025-05-22 14:23:09 +02:00
3b67ce0e5a add: tracking
Add Plausible
2025-05-22 10:57:42 +02:00
0a71a62af8 add: more lp stuff
Added timeline to LP
2025-05-22 10:39:06 +02:00
c7286a60da add: lp and navigation
Added... a lot of stuff
2025-05-22 10:12:47 +02:00
fb10e5b746 Merge pull request 'fix: normalized and centralized sizes and padding & landingpage structure' () from landingpage into main
Reviewed-on: 
2025-05-22 10:01:29 +02:00
f60719fa9e add: lp and navigation
Added... a lot of stuff
2025-05-22 09:59:27 +02:00
55fc3fe4e0 fix: textfield-padding 2025-05-21 13:28:57 +02:00
704fdaf27d Merge pull request 'add: sitemap + robots plugin' () from sitemap into main
Reviewed-on: 
2025-05-21 13:18:11 +02:00
d34096d3c8 add: sitemap + robots plugin 2025-05-20 11:22:47 +02:00
dd707bbf62 add: general structure for landingpage design 2025-05-15 14:12:40 +02:00
e591c276f5 fix: normalized and centralized sizes and padding 2025-05-14 10:58:19 +02:00
38cd37cf74 add: only delete swipe, edit on tap
Edit price cards on tap, only delete swipe exists, fixed dialogs, add ripple
2025-05-12 14:56:05 +02:00
1a5dd102e0 fix: more color fixes
Fix colors in dialogs
2025-05-12 12:58:46 +02:00
5e70236eac fix: header and subheader
Subheader claim always in now
2025-05-12 09:38:41 +02:00
cc54cb8112 fix: black text for h1
Added black text and centering for h1 text
2025-05-12 09:23:58 +02:00
236397fc9c Merge pull request 'fix: sticky-header-border-effect' () from sticky-header-fix into main
Reviewed-on: 
2025-05-12 09:17:00 +02:00
bb346773bd Merge pull request 'Added SEO Basics' () from seo-basics into main
Reviewed-on: 
Reviewed-by: Fiona Lena Urban <fiona@webfussel.de>
2025-05-12 09:16:44 +02:00
3083b99898 fix: sticky-header-border-effect 2025-05-11 21:43:04 +02:00
33713419f6 fix: (seo-)headings for pages + css 2025-05-11 14:05:01 +02:00
9746896a43 add: nuxt-seo-utils basic functions 2025-05-11 14:03:59 +02:00
c95aa68ec2 add: padding for Imp/Priv + fix: padding-defaults 2025-05-11 11:19:23 +02:00
1504b8bfe9 fix: a lot of design flaws
Info text when empty, new inputs, minimum height of content fixed
2025-05-11 11:07:19 +02:00
cef5330567 add: logo
ProPapier Logo, different claim
2025-05-10 21:10:32 +02:00
1bd69c9c97 add: subheader
Subheader for dynamic content
2025-05-10 18:36:42 +02:00
4b07ebb2ec add: search bar
Add new header and search bar
2025-05-10 18:07:18 +02:00
0aa495e05b add: reactive store
Add useLocalStorage from VueUse
2025-05-10 14:33:49 +02:00
9498911e7a add: reactive store
Add useLocalStorage from VueUse
2025-05-10 14:32:40 +02:00
27f051cf14 add: wording, vibration
Adapt wordings, add small vibration on edit and delete
2025-05-10 13:59:25 +02:00
3f398a0081 Merge pull request 'add: inputmode for better UI' () from inputmode into main
Reviewed-on: 
2025-05-10 13:50:08 +02:00
0133475e2a add: input mode
Add input mode for decimals on number fields
2025-05-10 13:48:39 +02:00
9a953980dc add: inputmode for better UI 2025-05-10 13:41:50 +02:00
4b22115159 remove: console.log
Removed useless console.log
2025-05-10 13:41:10 +02:00
70348d85ee Merge pull request 'add: new list layout design' () from feature/list-layout into main
Reviewed-on: 
2025-05-10 13:35:49 +02:00
11bcdce6cb add: new list layout design
New design, custom uuid, better handling of swipe
2025-05-10 13:32:28 +02:00
50 changed files with 2848 additions and 741 deletions

17
app/app.vue Executable file → Normal file
View file

@ -1,5 +1,18 @@
<template> <template>
<PpHeader /> <PpNavigation />
<NuxtLayout>
<NuxtPage /> <NuxtPage />
<PpFooter /> </NuxtLayout>
</template> </template>
<style>
.page-enter-active,
.page-leave-active {
transition: all 200ms;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
filter: blur(.5rem);
}
</style>

View file

@ -1,6 +1,6 @@
.Button { .Button {
--padding: .2rem; --padding: var(--padding-xs);
--background: var(--color-gradient-main-dark); --background: var(--color-main-dark);
--color: var(--color-lightest); --color: var(--color-lightest);
--background-hover: var(--color-main-dark); --background-hover: var(--color-main-dark);
@ -8,7 +8,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 1rem; gap: var(--padding-default);
cursor: pointer; cursor: pointer;
transition: var(--transition-default); transition: var(--transition-default);
outline: none; outline: none;
@ -19,17 +19,17 @@
&.transparent { &.transparent {
--background: transparent; --background: transparent;
box-shadow: none; box-shadow: none;
padding: .5em 1.5em; padding: var(--padding-s) var(--padding-l);
border-radius: var(--radius-default); border-radius: var(--radius-default);
} }
&.raised { &.raised {
box-shadow: var(--box-shadow-z2); box-shadow: var(--box-shadow-z2);
padding: .5em 1.5em; padding: var(--padding-s) var(--padding-l);
border-radius: var(--radius-default); border-radius: var(--radius-default);
&.danger { &.danger {
--background: var(--color-gradient-error); --background: var(--color-error);
--color: var(--color-lightest); --color: var(--color-lightest);
} }
} }
@ -37,7 +37,7 @@
&.text { &.text {
--background: transparent; --background: transparent;
--color: var(--color-darkest); --color: var(--color-darkest);
padding: .5em 1.5em; padding: var(--padding-s) var(--padding-l);
border-radius: var(--radius-default); border-radius: var(--radius-default);
&:hover { &:hover {
@ -62,13 +62,13 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border-radius: 100%; border-radius: 100%;
padding: .5rem; padding: var(--padding-s);
} }
&.cta { &.cta {
background: var(--background); background: var(--background);
color: var(--color); color: var(--color);
padding: .5rem 1.5rem; padding: var(--padding-s) var(--padding-l);
border-radius: var(--radius-default); border-radius: var(--radius-default);
box-shadow: var(--box-shadow-z2); box-shadow: var(--box-shadow-z2);
@ -93,18 +93,30 @@
} }
} }
&.search-button {
--background: var(--color-lightest);
--color: var(--color-main-darkest);
border-radius: 100%;
padding: var(--padding);
font-size: var(--font-size-l);
&:hover {
scale: 1.2;
}
}
&.mini-button { &.mini-button {
padding: .5rem 1.5rem; padding: var(--padding-s) var(--padding-l);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .5rem; gap: var(--padding-xs);
& > .icon { & > .icon {
font-size: 1.5rem; font-size: var(--font-size-xl);
} }
& > span { & > span {
font-size: .8rem; font-size: var(--font-size-s);
} }
} }
} }

View file

@ -1,9 +1,6 @@
.ButtonGroup { .ButtonGroup {
display: flex; display: flex;
background: var(--color-main); background: var(--color-main);
border-radius: var(--radius-default);
overflow: hidden;
box-shadow: var(--box-shadow-z2);
& button { & button {
--color: var(--color-light); --color: var(--color-light);
@ -12,8 +9,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: .5rem; gap: var(--padding-xs);
padding: .5rem; padding: var(--padding-s);
flex-grow: 1; flex-grow: 1;
background: var(--background); background: var(--background);
color: var(--color); color: var(--color);
@ -24,13 +21,5 @@
--color: var(--color-lightest); --color: var(--color-lightest);
--background: var(--color-main-dark); --background: var(--color-main-dark);
} }
&:first-child {
border-radius: var(--radius-default) 0 0 var(--radius-default);
}
&:last-child {
border-radius: 0 var(--radius-default) var(--radius-default) 0;
}
} }
} }

View file

@ -0,0 +1,71 @@
dialog {
top: 50%;
left: 50%;
width: clamp(400px, 100vw, calc(var(--page-max-width) - var(--padding-xxl) * 2));
translate: -50% -50%;
border: none;
border-radius: var(--radius-default);
background: var(--color-lightest);
font-size: var(--font-size-default);
color: var(--color-darkest);
position: relative;
opacity: 0;
scale: 0;
transition:
opacity var(--transition-default) ease-out,
scale var(--transition-default) ease-out,
overlay var(--transition-default) ease-out allow-discrete,
display var(--transition-default) ease-out allow-discrete;
&[open] {
opacity: 1;
scale: 1;
&::backdrop {
background-color: rgb(0 0 0 / 25%);
}
}
&::backdrop {
z-index: 2000;
background-color: rgb(0 0 0 / 0%);
transition:
display var(--transition-default) allow-discrete,
overlay var(--transition-default) allow-discrete,
background-color var(--transition-default);
}
& > .wrapper {
display: flex;
flex-direction: column;
gap: var(--padding-default);
}
& header {
justify-content: space-between;
align-items: center;
padding: var(--padding-default) var(--padding-default) 0;
}
main {
padding: 0 var(--padding-default);
}
& footer {
justify-content: space-between;
padding: 0 var(--padding-default) var(--padding-default);
}
}
@starting-style {
dialog[open] {
opacity: 0;
scale: 0;
&::backdrop {
background-color: rgb(0 0 0 / 0%);
}
}
}

View file

@ -1,38 +1,49 @@
.Footer { .Footer {
position: relative; position: relative;
background: var(--color-darkest); background: var(--color-darkest);
padding: 1rem; padding: var(--padding-default);
z-index: 100; z-index: 100;
& h4 { & h4 {
color: var(--color-lightest); color: var(--color-lightest);
text-align: center; text-align: center;
margin-bottom: 1rem; margin-bottom: var(--padding-default);
} }
& .bottom { & .bottom {
display: flex; display: flex;
gap: 1rem; justify-content: center;
justify-content: space-between;
color: var(--color-light); color: var(--color-light);
} }
& .copy {
font-size: var(--font-size-xs);
color: var(--color-light);
margin-top: var(--padding-default);
text-align: center;
& a {
color: var(--color-main);
text-decoration: none;
}
}
& .socials { & .socials {
font-size: 1.5rem; font-size: var(--font-size-xl);
justify-content: center; justify-content: center;
margin-bottom: 2rem; margin-bottom: var(--padding-xl);
} }
& .data-links { & .data-links {
justify-content: flex-end; justify-content: flex-end;
font-size: .8rem; font-size: var(--font-size-s);
} }
& ul { & ul {
list-style: none; list-style: none;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: var(--padding-default);
& a { & a {
color: var(--color-lightest); color: var(--color-lightest);

View file

@ -0,0 +1,15 @@
.Search {
border-radius: 9999px;
background: var(--color-lightest);
padding: 0 var(--padding-default);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--padding-default);
& > input {
all: unset;
flex-grow: 1;
padding: var(--padding-s) 0;
}
}

View file

@ -0,0 +1,72 @@
.TextField {
--border-color: var(--color-light);
--label-color: var(--color-middle);
--label-position-top: var(--font-size-default);
--label-position-left: 2.5rem;
--label-font-size: var(--font-size-default);
--message-color: var(--color-middle);
position: relative;
flex: 25% 1 0;
&:focus-within {
--border-color: var(--color-main-dark);
}
&:focus-within,
&:has(input:not(:placeholder-shown)) {
--label-color: var(--color-main-dark);
}
&.error {
--label-color: var(--color-error) !important;
--border-color: var(--color-error);
--message-color: var(--color-error);
}
& .wrapper {
display: flex;
align-items: center;
padding: var(--padding-xxs) var(--padding-xs);
border: 1px solid var(--border-color);
border-radius: var(--radius-default);
}
& label {
position: absolute;
display: flex;
align-items: center;
color: var(--label-color);
gap: 2px;
top: -6px;
left: calc(var(--padding-xs) - 2px);
font-size: var(--font-size-s);
transition: var(--transition-default);
& > * {
background: var(--color-lightest);
padding: 0 2px;
}
}
& .icon {
position: relative;
color: var(--label-color);
font-size: var(--font-size-default);
top: -1px;
}
& input {
all: unset;
padding: var(--padding-xxs) var(--padding-s) var(--padding-xxs) 0;
font-size: var(--font-size-s);
width: 100%;
flex: 25% 1 0;
color: var(--color-darkest);
}
& > span {
color: var(--message-color);
font-size: var(--font-size-xs);
}
}

View file

@ -1,52 +0,0 @@
.Input {
&.error {
& .input-wrapper {
border-color: var(--color-error);
outline-width: 2px;
}
& span {
color: var(--color-error);
}
}
& span {
font-size: .65em;
}
& .input-wrapper {
position: relative;
flex: 25% 1 0;
border: 2px solid var(--color-main-dark);
border-radius: var(--radius-default);
overflow: hidden;
transition: var(--transition-default);
outline: 0 solid var(--color-lightest);
& label {
position: absolute;
font-size: .8em;
top: .3rem;
left: .5rem;
transition: var(--transition-default);
}
& input {
all: unset;
width: calc(100% - 1rem);
padding: 1.3rem .5rem .5rem .5rem;
background: var(--color-lightest);
&[type="number"] {
text-align: right;
}
}
& input:focus,
& input:not(:placeholder-shown) {
& + label {
color: var(--color-main-dark);
}
}
}
}

View file

@ -1,30 +1,25 @@
:root { :root {
--padding-default: 1rem;
--padding-small: .5rem;
--radius-default: 3px;
--transition-default: 150ms;
--color-success: #328104; --color-success: #328104;
--color-error: #A20606; --color-error: #a20606;
--color-blue-light: #0DDCE7; --color-blue-light: #d7e1f1;
--color-blue: #05B0FF; --color-blue: #05b0ff;
--color-blue-dark: #0266F2; --color-blue-dark: #0266f2;
--color-blue-darkest: #013174; --color-blue-darkest: #013174;
--color-darkest: #292929; --color-darkest: #292929;
--color-dark: #404040; --color-dark: #404040;
--color-middle: #707070; --color-middle: #707070;
--color-light: #E0E0E6; --color-light: #e0e0e6;
--color-lightest: #FAFAFF; --color-lightest: #fafaff;
--color-green-light: #05FFC5;
--color-green: #02F276;
--color-green-dark: #09DC33;
--color-green-darkest: #07B029;
--color-green-darkest-most: #157C2A;
--color-green-light: #05ffc5;
--color-green: #02f276;
--color-green-dark: #09dc33;
--color-green-darkest: #07b029;
--color-green-darkest-most: #157c2a;
--color-main: var(--color-blue); --color-main: var(--color-blue);
--color-main-lightest: var(--color-blue-light);
--color-main-light: var(--color-blue-light); --color-main-light: var(--color-blue-light);
--color-main-dark: var(--color-blue-dark); --color-main-dark: var(--color-blue-dark);
--color-main-darkest: var(--color-blue-darkest); --color-main-darkest: var(--color-blue-darkest);
@ -34,17 +29,44 @@
--color-accent-dark: var(--color-green-dark); --color-accent-dark: var(--color-green-dark);
--color-accent-darkest: var(--color-green-darkest); --color-accent-darkest: var(--color-green-darkest);
--color-text: var(--color-darkest);
--color-text-invert: var(--color-lightest);
--color-gradient-main: linear-gradient(to bottom right, var(--color-main), var(--color-main-light)); --color-gradient-main: linear-gradient(to bottom right, var(--color-main), var(--color-main-light));
--color-gradient-main-dark: linear-gradient(to bottom right, var(--color-main-darkest), var(--color-main-dark)); --color-gradient-main-dark: linear-gradient(to bottom right, var(--color-main-darkest), var(--color-main-dark));
--color-gradient-accent: linear-gradient(to bottom right, var(--color-accent), var(--color-accent-light)); --color-gradient-accent: linear-gradient(to bottom right, var(--color-accent), var(--color-accent-light));
--color-gradient-accent-dark: linear-gradient(to bottom right, var(--color-accent-darkest), var(--color-accent-dark)); --color-gradient-accent-dark: linear-gradient(to bottom right, var(--color-accent-darkest), var(--color-accent-dark));
--color-gradient-error: linear-gradient(to bottom right, #B00707, #DC0909); --color-gradient-error: linear-gradient(to bottom right, #b00707, #dc0909);
--color-gradient-error-reverse: linear-gradient(to top left, #B00707, #DC0909); --color-gradient-error-reverse: linear-gradient(to top left, #b00707, #dc0909);
--box-shadow-upper: 0 -3px 6px rgba(0,0,0,0.16), 0 -3px 6px rgba(0,0,0,0.23); --box-shadow-upper: 0 -3px 6px rgba(0, 0, 0, 0.16), 0 -3px 6px rgba(0, 0, 0, 0.23);
--box-shadow-z2: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); --box-shadow-z2: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
--box-shadow-inset: inset 0 3px 6px rgba(0,0,0,0.16), inset 0 3px 6px rgba(0,0,0,0.23); --box-shadow-inset: inset 0 3px 6px rgba(0, 0, 0, 0.16), inset 0 3px 6px rgba(0, 0, 0, 0.23);
/* Font Sizes & Scaling Factor*/
--scaling-factor: 1.25;
--font-size-xs: calc(var(--font-size-s) / var(--scaling-factor));
--font-size-s: calc(var(--font-size-default) / var(--scaling-factor));
--font-size-default: 1rem;
--font-size-l: calc(var(--font-size-default) * var(--scaling-factor));
--font-size-xl: calc(var(--font-size-l) * var(--scaling-factor));
--font-size-xxl: calc(var(--font-size-xl) * var(--scaling-factor));
/* Paddings depend on Font-Size */
--padding-xxs: calc(var(--padding-xs) / var(--scaling-factor));
--padding-xs: calc(var(--padding-s) / var(--scaling-factor));
--padding-default: var(--font-size-default);
--padding-s: calc(var(--padding-default) / var(--scaling-factor));
--padding-l: calc(var(--padding-default) * var(--scaling-factor));
--padding-xl: calc(var(--padding-l) * var(--scaling-factor));
--padding-xxl: calc(var(--padding-xl) * var(--scaling-factor));
--radius-default: calc(var(--font-size-default) / 3);
--radius-border: var(--font-size-default);
--transition-default: 150ms;
--page-max-width: 820px;
} }
* { * {
@ -58,43 +80,35 @@ body {
height: 100%; height: 100%;
overflow-x: hidden; overflow-x: hidden;
font-family: sans-serif; font-family: sans-serif;
} color: var(--color-text);
font-size: var(--font-size-default);
.dot { max-width: var(--page-max-width);
--size: 10px; margin: 0 auto;
width: var(--size); background-image: url("/img/desktop-background.svg");
height: var(--size); background-position: center;
top: 5px;
right: 25%;
border-radius: 50%;
background-color: var(--color-accent);
display: inline-block;
margin-right: 0.5rem;
position: absolute;
box-shadow: var(--box-shadow-z2); box-shadow: var(--box-shadow-z2);
scale: 0;
transition: var(--transition-default);
&.visible {
scale: 1;
animation: pulse 1s infinite;
}
} }
@keyframes pulse { h1, h2, h3 {
0% { margin: var(--padding-xl) 0 var(--padding-default);
transform: scale(0.95); text-wrap: balance;
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); hyphens: auto;
} font-size: var(--font-size-xl);
70% { }
transform: scale(1);
box-shadow: 0 0 0 10px rgba(255, 255, 255, 0);
}
100% { h2 {
transform: scale(0.95); margin: var(--padding-l) 0 var(--padding-default);
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); font-size: var(--font-size-l);
} }
h3 {
margin: var(--padding-default) 0 var(--padding-default);
font-size: var(--font-size-default);
}
a:has(button) {
text-decoration: none;
} }
.card { .card {
@ -103,20 +117,6 @@ body {
box-shadow: var(--box-shadow-z2); box-shadow: var(--box-shadow-z2);
} }
.content {
padding: 1rem;
min-height: 100dvh;
}
.filter-bar {
margin-bottom: 1rem;
}
.pc-wrapper {
gap: 1rem;
margin-bottom: 1rem;
}
.flex-col { .flex-col {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -128,7 +128,7 @@ body {
} }
.text-white { .text-white {
color: var(--color-lightest); color: var(--color-text-invert);
} }
.bg-main { .bg-main {
@ -143,12 +143,16 @@ body {
background-color: var(--color-main-dark); background-color: var(--color-main-dark);
} }
.bg-main-darkest {
background-color: var(--color-main-darkest);
}
.bg-main-dark-hover:hover { .bg-main-dark-hover:hover {
background-color: var(--color-main-dark); background-color: var(--color-main-dark);
} }
.gap-default { .gap-default {
gap: 1rem; gap: var(--padding-default);
} }
.bg-blue { .bg-blue {
@ -160,35 +164,28 @@ body {
} }
.padding { .padding {
gap: 1rem; gap: var(--padding-default);
padding: var(--padding-default); padding: var(--padding-default);
} }
.padding-small { .padding-small {
gap: 1rem; gap: var(--padding-default);
padding: var(--padding-small); padding: var(--padding-s);
} }
dialog { .roboto-condensed {
top: 50%; font-family: "Roboto Condensed", sans-serif;
left: 50%; }
width: 100vw;
transform: translate(-50%, -50%); .roboto {
border: none; font-family: "Roboto", sans-serif;
border-radius: var(--radius-default); }
font-size: 1rem; .open-sans {
font-family: "Open Sans", sans-serif;
& header { }
justify-content: space-between;
align-items: center; .grow {
} flex-grow: 1;
height: var(--padding-s);
& footer {
justify-content: space-between;
}
&::backdrop {
background: rgba(0, 0, 0, 0.5);
}
} }

View file

@ -1,57 +1,88 @@
.Header { .Header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--padding-default);
background-color: rgba(255 255 255 / .8);
backdrop-filter: blur(10px);
box-shadow: var(--box-shadow-z2);
color: var(--color-darkest);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
display: flex;
& strong {
font-size: 2em;
& span {
color: var(--color-main-dark);
}
}
& input[type="checkbox"] {
display: none;
}
& input[type="checkbox"]:checked + nav {
translate: 0;
}
& nav,
& ul {
gap: 1em;
}
& nav {
position: fixed;
padding: var(--padding-default);
translate: 100% 0;
width: 100vw;
right: 0;
top: 0;
height: 100dvh;
transition: 150ms ease-in-out;
background: var(--color-lightest);
font-size: 2em;
align-items: end;
z-index: 100;
}
& ul {
width: 100%;
align-items: center; align-items: center;
& li { gap: var(--padding-default);
list-style: none; padding: var(--padding-default);
&:not(.lp) {
background: var(--color-main-darkest);
}
&.lp {
position: absolute;
background: rgba(0,0,0,.5);
backdrop-filter: blur(10px);
mask: linear-gradient(to top, transparent, black 30%);
width: 100%;
max-width: var(--page-max-width);
top: 0;
padding-bottom: var(--padding-xxl);
}
& .logo {
height: 40px;
}
& .burger-button {
all: unset;
color: var(--color-lightest);
}
& header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-weight: bold;
& a {
text-decoration: none;
color: var(--color-lightest);
}
& .header-text {
display: flex;
align-items: center;
gap: var(--padding-default);
& > div {
display: flex;
flex-direction: column;
}
& .big {
font-size: var(--font-size-xl);
}
& .small {
font-size: var(--font-size-s);
font-weight: 100;
}
}
}
&:not(.lp) {
&:after, &:before {
content: '';
display: block;
position: absolute;
bottom: calc(-1 * var(--radius-border));
background: var(--color-blue-darkest);
width: var(--radius-border);
height: var(--radius-border);
}
&:after {
right: 0;
mask: radial-gradient(var(--radius-border) at 0 100%,#0000 98%,#000);
}
&:before {
left: 0;
mask: radial-gradient(var(--radius-border) at 100% 100%,#0000 98%,#000);
} }
} }
} }

View file

@ -0,0 +1,40 @@
.home-hero {
background-image: linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.6)), url("/img/hero-image.webp"); /* single color gradient for dark layer over image */
background-repeat: no-repeat;
background-position: center center;
background-size: cover;
color: var(--color-text-invert);
text-align: center;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
button {
margin: var(--padding-xxl) auto;
}
.text {
padding: var(--padding-default);
}
h1 {
font-size: var(--font-size-xxl);
}
}
.home-text {
padding: var(--padding-xxl) var(--padding-default);
text-align: center;
& h3:has(+ .padding) {
margin-bottom: 0;
}
}
.timeline {
display: flex;
flex-direction: column;
gap: var(--padding-default);
}

View file

@ -0,0 +1,69 @@
.Navigation {
position: fixed;
display: flex;
justify-content: flex-end;
align-items: center;
z-index: 5000;
width: 100vw;
height: 100dvh;
right: 0;
top: 0;
transition: 150ms ease-in-out;
pointer-events: none;
&.open {
pointer-events: all;
background: rgba(0, 0, 0, .5);
& nav {
translate: -1rem 0;
}
}
nav {
background: var(--color-lightest);
align-items: end;
box-shadow: var(--box-shadow-z2);
padding: var(--padding-default);
height: calc(100% - var(--padding-xxl));
width: 70%;
translate: 100% 0;
transition: 150ms ease-in-out;
border-radius: var(--radius-default);
}
& button {
justify-self: flex-end;
font-size: var(--font-size-xl);
}
& ul {
width: 100%;
align-items: flex-start;
font-size: var(--font-size-l);
gap: var(--padding-default);
& li {
list-style: none;
width: 100%;
& a {
text-decoration: none;
display: flex;
align-items: center;
gap: var(--padding-default);
color: var(--color-middle);
border-radius: var(--radius-default);
padding: var(--padding-xs) var(--padding-s);
transition: var(--transition-default);
background: transparent;
cursor: pointer;
&.active {
background: var(--color-main-light);
color: var(--color-main-dark);
}
}
}
}
}

View file

@ -0,0 +1,84 @@
.page-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
& .page {
flex-grow: 1;
& .nuxt-page-wrapper {
height: 100%;
justify-content: space-between;
}
}
}
.filter-bar {
background: var(--color-lightest);
display: flex;
justify-content: space-between;
padding: var(--padding-default);
& > button {
all: unset;
cursor: pointer;
color: var(--color-main-darkest);
font-weight: bolder;
font-family: 'Roboto', sans-serif;
&.active {
color: var(--color-main-darkest);
}
&:not(.active) {
opacity: .5;
}
}
}
.search-bar {
z-index: 100;
& p {
font-weight: 100;
color: var(--color-lightest);
}
}
.content {
overflow: hidden;
background: var(--color-lightest);
height: 100%;
h1:first-of-type,
h2:first-of-type,
h3:first-of-type,
p:first-of-type,
figure:first-of-type {
margin-top: 0;
}
}
.content-text {
padding: var(--padding-xl) var(--padding-default) 0;
color: var(--color-darkest);
text-align: center;
}
.info-text {
padding: 0 var(--padding-default);
text-align: center;
width: 100%;
top: 40%;
transform: translateY(-50%);
position: absolute;
}
.Legal {
padding: var(--padding-l) var(--padding-default);
color: var(--color-darkest);
ul {
padding-left: var(--padding-default);
}
}

View file

@ -1,11 +1,8 @@
.PriceCard { .PriceCard {
position: relative; position: relative;
overflow: hidden;
width: 100%; width: 100%;
transition: 150ms;
opacity: 1;
color: var(--color-darkest); color: var(--color-darkest);
background: black; border-bottom: 1px dashed var(--color-light);
.bottom { .bottom {
position: absolute; position: absolute;
@ -18,7 +15,7 @@
& > * { & > * {
flex-grow: 1; flex-grow: 1;
color: var(--color-lightest); color: var(--color-lightest);
font-size: 2rem; font-size: var(--font-size-xl);
display: flex; display: flex;
align-items: center; align-items: center;
@ -28,14 +25,14 @@
} }
& .bg-edit { & .bg-edit {
background: var(--color-gradient-main-dark); background: var(--color-main-dark);
padding: 2rem; padding: var(--padding-xl);
text-align: left; text-align: left;
} }
& .bg-delete { & .bg-delete {
background: var(--color-gradient-error-reverse); background: var(--color-error);
padding: 2rem; padding: var(--padding-xl);
text-align: right; text-align: right;
justify-content: flex-end; justify-content: flex-end;
} }
@ -45,9 +42,8 @@
position: relative; position: relative;
background: var(--color-lightest); background: var(--color-lightest);
z-index: 2; z-index: 2;
gap: 1rem; gap: var(--padding-default);
padding: 1rem; padding: var(--padding-default);
border-radius: var(--radius-default);
&.animated { &.animated {
transition: var(--transition-default); transition: var(--transition-default);
@ -60,14 +56,14 @@
color: var(--color-darkest); color: var(--color-darkest);
& .icon { & .icon {
font-size: 1rem; font-size: var(--font-size-default);
cursor: pointer; cursor: pointer;
} }
} }
& .name-price { & .name-price {
display: flex; display: flex;
gap: .5rem; gap: var(--padding-xs);
& > span:nth-child(1) { & > span:nth-child(1) {
font-weight: bold; font-weight: bold;
@ -79,7 +75,7 @@
& > span:nth-child(2)::before { & > span:nth-child(2)::before {
content: '•'; content: '•';
margin-right: .5rem; margin-right: var(--padding-xs);
color: var(--color-middle); color: var(--color-middle);
} }
} }
@ -88,19 +84,19 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
gap: 1rem; gap: var(--padding-default);
justify-content: space-between; justify-content: space-between;
& > .info { & > .info {
flex-grow: 0; flex-grow: 0;
align-items: center; align-items: center;
gap: .25rem; gap: var(--padding-xxs);
font-weight: bold; font-weight: bold;
& > .price { & > .price {
display: flex; display: flex;
align-items: center; align-items: center;
gap: .5rem; gap: var(--padding-xs);
& > .icon { & > .icon {
color: var(--color-main-dark); color: var(--color-main-dark);
@ -108,7 +104,7 @@
} }
& > .pro { & > .pro {
font-size: .6rem; font-size: var(--font-size-xs);
color: var(--color-middle); color: var(--color-middle);
font-weight: lighter; font-weight: lighter;
} }

View file

@ -0,0 +1,28 @@
.TimelineCard {
display: flex;
align-items: center;
gap: var(--padding-default);
border: 1px solid var(--color-light);
border-radius: var(--radius-default);
padding: var(--padding-xs);
& > .icon {
flex: 0 0 25%;
font-size: var(--font-size-xxl);
color: var(--color-main-dark);
}
& .text {
text-align: left;
flex-grow: 1;
}
& .state {
--color: var(--color-darkest);
display: flex;
align-items: center;
gap: var(--padding-xxs);
margin-top: var(--padding-s);
color: var(--color);
}
}

View file

@ -4,11 +4,10 @@
background: var(--color-main-darkest); background: var(--color-main-darkest);
position: sticky; position: sticky;
bottom: 0; bottom: 0;
z-index: 100; z-index: 1000;
box-shadow: var(--box-shadow-upper); box-shadow: var(--box-shadow-upper);
& > .Button { & > .Button {
--padding: 1rem; font-size: var(--font-size-default);
font-size: 1rem;
} }
} }

View file

@ -1,21 +1,21 @@
<template> <template>
<dialog <dialog
ref="dialog" ref="dialog"
closedby="any" closedby="none"
> >
<form method="dialog"> <form method="dialog" class="wrapper" ref="wrapper">
<header class="flex-row padding"> <header class="flex-row">
Wirklich löschen? Wirklich löschen?
<PpButton class="round text"> <PpButton class="round text">
<Icon name="uil:times" mode="svg" /> <Icon name="uil:times" mode="svg" />
</PpButton> </PpButton>
</header> </header>
<main> <main>
<div class="padding flex-col"> <div class="flex-col">
<p>Bist du dir sicher, dass du diesen Eintrag löschen möchtest?</p> <p>Bist du dir sicher, dass du diesen Eintrag löschen möchtest?</p>
</div> </div>
</main> </main>
<footer class="flex-row padding"> <footer class="flex-row">
<PpButton class="text"> <PpButton class="text">
<span>Abbrechen</span> <span>Abbrechen</span>
</PpButton> </PpButton>
@ -35,5 +35,11 @@ type Props = {
defineProps<Props>() defineProps<Props>()
defineEmits(['delete']) defineEmits(['delete'])
const dialog = useTemplateRef<HTMLDialogElement>('dialog')
const wrapper = useTemplateRef<HTMLElement>('wrapper')
onMounted(() => {
onClickOutside(wrapper, () => dialog.value?.close())
})
</script> </script>

View file

@ -9,7 +9,6 @@
</li> </li>
</ul> </ul>
<div class="bottom"> <div class="bottom">
<small>&copy; 2025 by webfussel</small>
<ul class="data-links"> <ul class="data-links">
<li v-for="dataLink in dataLinks"> <li v-for="dataLink in dataLinks">
<NuxtLink :to="dataLink.to"> <NuxtLink :to="dataLink.to">
@ -18,6 +17,8 @@
</li> </li>
</ul> </ul>
</div> </div>
<p class="copy">ProPapier ist ein Gemeinschaftsprojekt von <NuxtLink to="https://webertoire.de" external>webertoire</NuxtLink> und <NuxtLink to="https://webfussel.de" external>webfussel</NuxtLink></p>
<p class="copy">&copy; 2025 by webfussel, webertoire</p>
</footer> </footer>
</template> </template>

View file

@ -0,0 +1,25 @@
<template>
<div class="Search">
<input
v-model="text"
:id="id"
:placeholder="label"
@blur="emit('search', text)"
/>
<PpButton class="search-button">
<Icon name="uil:search" mode="svg" />
</PpButton>
</div>
</template>
<script setup lang="ts">
type Props = {
label : string
id : string
}
defineProps<Props>()
const emit = defineEmits(['search'])
const text = defineModel()
</script>

View file

@ -0,0 +1,30 @@
<template>
<div class="TextField">
<div class="wrapper">
<input v-model="text" :type="type" :id="id" :placeholder="placeholder" @blur="emit('blur')" @input="emit('input')" :inputmode="mode" />
<label :for="id">
<Icon v-if="icon" class="icon" :name="icon" mode="svg" />
<span>{{ label }}</span>
</label>
</div>
<span v-if="message">{{ message }}</span>
</div>
</template>
<script setup lang="ts">
type Props = {
type?: 'text' | 'number'
message?: string
icon?: string
label: string
placeholder: string
id: string
mode?: 'text' | 'email' | 'search' | 'tel' | 'url' | 'none' | 'numeric' | 'decimal'
};
const { type = "text", mode = "text" } = defineProps<Props>();
const emit = defineEmits(['blur', 'input']);
const text = defineModel();
</script>

View file

@ -1,43 +0,0 @@
<template>
<div class="Input">
<div class="input-wrapper flex-col">
<input
v-model="text"
:type="type"
:id="id"
:step="step"
:min="min"
:max="max"
:required="required"
placeholder=" "
@blur="emit('blur')"
/>
<label :for="id">{{ label }}</label>
</div>
<span v-if="message">{{ message }}</span>
</div>
</template>
<script setup lang="ts">
type Props = {
type ?: 'text' | 'number'
max ?: number
min ?: number
step ?: number
required ?: boolean
message ?: string
label : string
id : string
}
const {
type = 'text',
required = false,
step = 0.01,
min = 1,
} = defineProps<Props>()
const emit = defineEmits(['blur'])
const text = defineModel()
</script>

View file

@ -1,31 +1,28 @@
<template> <template>
<header class="Header"> <div class="Header" :class="[type]">
<NuxtLink to="/"> <header class="roboto-condensed">
<strong><span>Pro</span>Papier</strong> <NuxtLink class="header-text" to="/">
<img class="logo" src="/img/propapier.svg" alt="ProPapier logo" />
<div>
<span class="big">ProPapier</span>
<span class="small">Vergleichen. Schnell. Unkompliziert.</span>
</div>
</NuxtLink> </NuxtLink>
<label for="burger_nav_toggle" v-if="available"> <button class="burger-button" @click="open()">
<Icon name="solar:hamburger-menu-broken" size="2em" /> <Icon name="uil:bars" size="2em" mode="svg" />
</label> </button>
<input type="checkbox" id="burger_nav_toggle" v-if="available" />
<nav class="flex-col" v-if="available">
<label for="burger_nav_toggle">
<Icon name="solar:close-circle-broken" />
</label>
<ul class="flex-col">
<li>Home</li>
<li>Übersicht</li>
</ul>
</nav>
</header> </header>
<div id="subheader" />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const available = false type Props = {
</script> type ?: 'lp'
}
<style scoped> defineProps<Props>()
header a {
text-decoration: none; const nav = useNavigation()
color: var(--color-black); const open = () => { nav.showNavigation() }
} </script>
</style>

View file

@ -0,0 +1,41 @@
<template>
<section class="Navigation" :class="{ open }">
<nav>
<PpButton class="round text" @click="close()">
<Icon name="uil:times" mode="svg" />
</PpButton>
<ul class="flex-col">
<li v-for="page in pages">
<NuxtLink :to="page.route" @click="close()" active-class="active">
<Icon class="icon" :name="`uil:${page.icon}`" mode="svg" />
<span>{{ page.label }}</span>
</NuxtLink>
</li>
</ul>
</nav>
</section>
</template>
<script setup lang="ts">
const nav = useNavigation()
const close = () => nav.hideNavigation()
const open = computed(() => nav.isNavigationVisible.value)
const pages = [
{
label: 'Home',
icon: 'home',
route: '/'
},
{
label: 'Schnellrechner',
icon: 'calculator',
route: '/rechner'
},
// {
// label: 'Über uns',
// icon: 'users-alt',
// route: '/about-us'
// }
]
</script>

View file

@ -1,5 +1,5 @@
<template> <template>
<article class="PriceCard card"> <article class="PriceCard roboto-condensed" v-ripple="$device.isMobile ? { color: 'rgba(0, 0, 0, 0.1)' } : { duration: 0, scale: 0 }">
<div class="bottom"> <div class="bottom">
<div class="bg-edit"> <div class="bg-edit">
<Icon class="icon" name="uil:pen" mode="svg" /> <Icon class="icon" name="uil:pen" mode="svg" />
@ -13,6 +13,7 @@
class="top flex-col" class="top flex-col"
:class="{ 'animated' : !isSwiping }" :class="{ 'animated' : !isSwiping }"
:style="{ left }" :style="{ left }"
@click="cardClick"
> >
<header> <header>
<div class="name-price"> <div class="name-price">
@ -20,10 +21,10 @@
<span>{{ intl.format(+replaceComma(card.price))}}</span> <span>{{ intl.format(+replaceComma(card.price))}}</span>
</div> </div>
<div v-if="$device.isDesktop" class="flex-row gap-default"> <div v-if="$device.isDesktop" class="flex-row gap-default">
<PpButton class="icon-button" @click="update()"> <PpButton class="icon-button" @click="update">
<Icon class="icon" name="uil:pen" mode="svg" /> <Icon class="icon" name="uil:pen" mode="svg" />
</PpButton> </PpButton>
<PpButton class="icon-button" @click="deleteCard()"> <PpButton class="icon-button" @click="deleteCard">
<Icon class="icon" name="uil:trash-alt" mode="svg" /> <Icon class="icon" name="uil:trash-alt" mode="svg" />
</PpButton> </PpButton>
</div> </div>
@ -33,23 +34,23 @@
<div class="info flex-col"> <div class="info flex-col">
<div class="price"> <div class="price">
<Icon class="icon" name="uil:toilet-paper" mode="svg" /> <Icon class="icon" name="uil:toilet-paper" mode="svg" />
<span class="value">{{ intl.format(card.ppr) }}</span> <span class="value">{{ intl.format(ppr) }}</span>
</div> </div>
<span class="pro">Pro 1 {{ card.roles ? `(${card.roles})` : '' }}</span> <span class="pro">Pro 1 {{ card.roles ? `(${card.roles})` : '' }}</span>
</div> </div>
<div class="info flex-col"> <div class="info flex-col">
<div class="price"> <div class="price">
<Icon class="icon" name="uil:file-landscape" mode="svg" /> <Icon class="icon" name="uil:file-landscape" mode="svg" />
<span class="value">{{ intl.format(card.pps) }}</span> <span class="value">{{ intl.format(pps) }}</span>
</div> </div>
<span class="pro">Pro 100 {{ card.sheets ? `(${card.sheets})` : '' }}</span> <span class="pro">Pro 100 {{ card.sheets ? `(${card.sheets})` : '' }}</span>
</div> </div>
<div class="info flex-col"> <div class="info flex-col">
<div class="price"> <div class="price">
<Icon class="icon" name="uil:layer-group" mode="svg" /> <Icon class="icon" name="uil:layer-group" mode="svg" />
<span class="value">{{ intl.format(card.ppl) }}</span> <span class="value">{{ intl.format(ppl) }}</span>
</div> </div>
<span class="pro">Pro 100 {{ card.layers ? `(${card.layers})` : '' }}</span> <span class="pro">Pro 1000 {{ card.layers ? `(${card.layers})` : '' }}</span>
</div> </div>
</main> </main>
</div> </div>
@ -57,16 +58,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Card } from '../../../shared/Card' import type { PriceCard } from '../../../shared/PriceCard'
type Props = { type Props = {
deletable: boolean deletable: boolean
card: Card card: PriceCard
} }
const { card } = defineProps<Props>() const { card } = defineProps<Props>()
const emit = defineEmits(['remove', 'update']) const emit = defineEmits(['remove', 'update'])
const { vibrate } = useVibrate()
const top = useTemplateRef('top') const top = useTemplateRef('top')
const left = shallowRef<string>('0') const left = shallowRef<string>('0')
@ -75,22 +77,40 @@ const intl = Intl.NumberFormat('de-DE', {
currency: 'EUR', currency: 'EUR',
}) })
const { lengthX, isSwiping } = useSwipe(top, { const { lengthX, direction, isSwiping } = useSwipe(top, {
passive: false, passive: true,
threshold: 30, threshold: 20,
onSwipe() { onSwipe() {
if (lengthX.value > 50 || lengthX.value < -50) { if (['down', 'up'].includes(direction.value)) return
left.value = `${-clamp(lengthX.value, -100, 100)}px` left.value = `${-clamp(lengthX.value, 0, 100)}px`
}
}, },
onSwipeEnd() { onSwipeEnd() {
if (lengthX.value < -50) update() if (['down', 'up'].includes(direction.value)) return
if (lengthX.value > 50) deleteCard() if (lengthX.value > 50) {
vibrate(100)
deleteCard()
}
left.value = '0' left.value = '0'
}, },
}) })
const update = () => emit('update') const priceClean = computed<number>(() => +replaceComma(card.price))
const ppr = computed(() => priceClean.value / +card.roles)
const pps = computed(() => (ppr.value / +card.sheets) * 100)
const ppl = computed(() => (pps.value / +card.layers) * 10)
const { isDesktop } = useDevice()
const cardClick = () => {
if (isDesktop) return
emit('update')
}
const update = () => emit('update')
const deleteCard = () => emit('remove') const deleteCard = () => emit('remove')
defineExpose({
ppr, pps, ppl, uuid : card.uuid,
})
</script> </script>

View file

@ -1,96 +1,134 @@
<template> <template>
<dialog <dialog ref="dialog" closedby="none">
ref="dialog" <div class="wrapper" ref="wrapper">
closedby="any"
>
<form method="dialog"> <form method="dialog">
<header class="flex-row padding"> <header class="flex-row">
{{ cardLabel }} {{ currentCardIndex > -1 ? 'Bearbeiten' : 'Neues hinzufügen' }}
<PpButton class="round text"> <PpButton class="round text">
<Icon name="uil:times" mode="svg" /> <Icon name="uil:times" mode="svg" />
</PpButton> </PpButton>
</header> </header>
</form> </form>
<main v-if="currentCard"> <main v-if="currentCard">
<div class="padding flex-col"> <div class="flex-col gap-default">
<div class="flex-row gap-default"> <div class="flex-row gap-default">
<PpFormInput <PpFormTextField
v-model="currentCard.name" v-model="currentCard.name"
id="card_name" id="card_name"
label="Name" label="Name"
:class="{'error': !validFields.name }" icon="uil:pricetag-alt"
:placeholder="randomName"
:class="{ error: !validFields.name }"
:message="!validFields.name ? 'Feld darf nicht leer sein.' : ''" :message="!validFields.name ? 'Feld darf nicht leer sein.' : ''"
@input="validFields.name = true"
/> />
<PpFormInput <PpFormTextField
v-model="currentCard.price" v-model="currentCard.price"
id="card_price" id="card_price"
label="Preis" label="Preis"
:class="{'error': !validFields.price }" placeholder="2,49"
icon="uil:euro"
mode="decimal"
:class="{ error: !validFields.price }"
:message="!validFields.price ? 'Muss eine Zahl sein.' : ''" :message="!validFields.price ? 'Muss eine Zahl sein.' : ''"
@input="validFields.price = true"
/> />
</div> </div>
<div class="flex-row gap-default"> <div class="flex-row gap-default">
<PpFormInput <PpFormTextField
v-model="currentCard.roles" v-model="currentCard.roles"
id="card_roles" id="card_roles"
label="Rollen" label="Rollen"
:class="{'error': !validFields.roles }" placeholder="8"
icon="uil:toilet-paper"
mode="decimal"
:class="{ error: !validFields.roles }"
:message="!validFields.roles ? 'Muss eine Ganzzahl sein.' : ''" :message="!validFields.roles ? 'Muss eine Ganzzahl sein.' : ''"
@input="validFields.roles = true"
/> />
<PpFormInput <PpFormTextField
v-model="currentCard.sheets" v-model="currentCard.sheets"
id="card_sheets" id="card_sheets"
label="Blätter" label="Blatt"
:class="{'error': !validFields.sheets }" placeholder="150"
icon="uil:file-landscape"
mode="decimal"
:class="{ error: !validFields.sheets }"
:message="!validFields.sheets ? 'Muss eine Ganzzahl sein.' : ''" :message="!validFields.sheets ? 'Muss eine Ganzzahl sein.' : ''"
@input="validFields.sheets = true"
/> />
<PpFormInput <PpFormTextField
v-model="currentCard.layers" v-model="currentCard.layers"
id="card_layers" id="card_layers"
label="Lagen" label="Lagen"
:class="{'error': !validFields.layers }" placeholder="3"
icon="uil:layer-group"
mode="decimal"
:class="{ error: !validFields.layers }"
:message="!validFields.layers ? 'Muss eine Ganzzahl sein.' : ''" :message="!validFields.layers ? 'Muss eine Ganzzahl sein.' : ''"
@input="validFields.layers = true"
/> />
</div> </div>
</div> </div>
</main> </main>
<footer class="flex-row padding"> <footer class="flex-row">
<form method="dialog"> <form method="dialog">
<PpButton class="danger text"> <PpButton class="danger text">
<span>Abbrechen</span> <span>Abbrechen</span>
</PpButton> </PpButton>
</form> </form>
<PpButton class="raised" @click="validate"> <PpButton class="raised" @click="validate">
<span>{{ cardLabel }}</span> <span>{{ currentCardIndex > -1 ? 'Übernehmen' : 'Hinzufügen' }}</span>
</PpButton> </PpButton>
</footer> </footer>
</div>
</dialog> </dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Card } from '../../../shared/Card' import type { PriceCard } from '../../../shared/PriceCard'
type Props = { type Props = {
currentCardIndex : number currentCardIndex: number
currentCard ?: Card currentCard?: PriceCard
} }
const { currentCardIndex, currentCard } = defineProps<Props>() const { currentCardIndex, currentCard } = defineProps<Props>()
const emit = defineEmits(['update']) const emit = defineEmits(['update'])
const dialog = useTemplateRef<HTMLDialogElement>('dialog') const dialog = useTemplateRef<HTMLDialogElement>('dialog')
const cardLabel = computed(() => currentCardIndex > -1 ? 'Bearbeiten' : 'Hinzufügen') const wrapper = useTemplateRef<HTMLElement>('wrapper')
const market = [
'Lotl',
'Olda',
'Bäwä',
'Brutto',
]
const product = [
'Weichelig',
'Sau Rauh',
'Bissl Sanft',
'Ganz ok',
'Flauschi'
]
const generateRandomName = () => `${market[Math.floor(Math.random() * market.length)]} ${product[Math.floor(Math.random() * product.length)]}`
const randomName = useState('randomName', () => generateRandomName())
const checkPrice = () => { const checkPrice = () => {
if (!currentCard) { return false } if (!currentCard) return false
if (currentCard.price.length === 0) { return false } if (currentCard.price.length === 0) return false
const price = +replaceComma(currentCard.price) const price = +replaceComma(currentCard.price)
return !isNaN(price) return !isNaN(price)
} }
const checkIfInteger = (toBeNumber : string) => { const checkIfInteger = (toBeNumber: string) => {
if (toBeNumber.length === 0) { return false } if (toBeNumber.length === 0) return false
if (toBeNumber.includes(',') || toBeNumber.includes('.')) { return false } if (toBeNumber.includes(',') || toBeNumber.includes(',')) return false
return !isNaN(+toBeNumber) return !isNaN(+toBeNumber)
} }
@ -103,7 +141,7 @@ const validFields = reactive({
}) })
const validate = () => { const validate = () => {
if (!currentCard) { return } if (!currentCard) return
validFields.name = currentCard.name.length > 0 validFields.name = currentCard.name.length > 0
validFields.price = checkPrice() validFields.price = checkPrice()
@ -111,7 +149,7 @@ const validate = () => {
validFields.sheets = checkIfInteger(currentCard.sheets) validFields.sheets = checkIfInteger(currentCard.sheets)
validFields.layers = checkIfInteger(currentCard.layers) validFields.layers = checkIfInteger(currentCard.layers)
if (Object.values(validFields).every(value => value)) { if (Object.values(validFields).every((value) => value)) {
emit('update') emit('update')
dialog.value?.close() dialog.value?.close()
} }
@ -124,6 +162,9 @@ onMounted(() => {
validFields.roles = true validFields.roles = true
validFields.sheets = true validFields.sheets = true
validFields.layers = true validFields.layers = true
randomName.value = generateRandomName()
}) })
onClickOutside(wrapper, () => dialog.value?.close())
}) })
</script> </script>

View file

@ -0,0 +1,51 @@
<template>
<article class="TimelineCard">
<Icon class="icon" :name="icon" mode="svg" />
<div class="text">
<strong>{{ title }}</strong>
<p>{{ description }}</p>
<div class="state" :style="{
'--color': stateColor,
}">
<Icon :name="stateIcon" mode="svg" />
<span>{{ stateMessage }}</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import type { TimelineCard, TimelineState } from '../../../shared/TimelineCard'
const { state } = defineProps<TimelineCard>()
const icons : Record<TimelineState, string> = {
planned: 'uil:clock',
inProgress: 'uil:cog',
done: 'uil:check-circle'
}
const colors : Record<TimelineState, string> = {
planned: 'var(--color-darkest)',
inProgress: 'var(--color-main-dark)',
done: 'var(--color-accent-darkest)',
}
const stateColor = computed(() => colors[state.value])
const stateIcon = computed(() => icons[state.value])
const stateMessage = computed(() => {
switch (state.value) {
case 'planned':
let planned = 'Geplant'
if (state.message) planned += ` für ${state.message}`
return planned
case 'inProgress':
return 'In Bearbeitung'
case 'done':
let done = 'Abgeschlossen'
if (state.message) done += ` am ${state.message}`
return done
}
})
</script>

View file

@ -0,0 +1,24 @@
import { ref } from 'vue'
const isNavigationVisible = ref(false)
export const useNavigation = () => {
const toggleNavigation = () => {
isNavigationVisible.value = !isNavigationVisible.value
}
const showNavigation = () => {
isNavigationVisible.value = true
}
const hideNavigation = () => {
isNavigationVisible.value = false
}
return {
isNavigationVisible,
toggleNavigation,
showNavigation,
hideNavigation
}
}

11
app/layouts/default.vue Executable file
View file

@ -0,0 +1,11 @@
<template>
<div class="page-wrapper">
<PpHeader />
<div class="page">
<NuxtPage />
</div>
<PpFooter />
</div>
</template>
<script setup lang="ts">
</script>

View file

@ -0,0 +1,11 @@
<template>
<div class="page-wrapper">
<PpHeader type="lp" />
<div class="page">
<NuxtPage />
</div>
<PpFooter />
</div>
</template>
<script setup lang="ts">
</script>

View file

@ -1,6 +1,7 @@
<template> <template>
<div> <div>
<section class="Imp flex-col gap-default content full"> <section class="Legal flex-col gap-default content full">
<h1>Impressum</h1>
<div> <div>
<p> <p>
Fiona Lena Urban<br/> Fiona Lena Urban<br/>

248
app/pages/index.vue Executable file → Normal file
View file

@ -1,185 +1,97 @@
<template> <template>
<PpDeleteDialog <section class="Home flex-col content full">
ref="deleteModal" <div class="home-hero">
:current-card-index="currentCardIndex" <div class="text">
@delete="removeCard(currentCardIndex)" <h1>
/> Du zahlst zuviel für's Papier?
<PpPriceCardDialog </h1>
ref="modal" <NuxtLink to="/rechner">
:current-card="currentCard" <PpButton class="cta">Preise vergleichen</PpButton>
:current-card-index="currentCardIndex" </NuxtLink>
@update="updateCard()" <h2>
/> Mit ProPapier Preise vergleichen und sparen.
<section class="content flex-col"> </h2>
<aside class="filter-bar"> </div>
<PpButtonGroup </div>
:buttons="filterButtons" <div class="home-text padding">
@click="sort" <p>
/> Mit <strong>ProPapier</strong> vergleichst du schnell & unkompliziert Preise für Klopapier und sparst so bares Geld.
</aside> </p>
<div class="pc-wrapper flex-col" role="list"> </div>
<PpPriceCard <div class="home-text padding">
v-for="(card, index) in cards" <h3>
:key="card.uuid" Wir haben noch viel vor!
:deletable="cards.length > 1" </h3>
:card="card" <p class="padding">
@update="openModal(false, index)" Für ProPapier sind über die nächste Zeit noch einige weitere Features geplant.
@remove="openDeleteModal()" </p>
<div class="timeline">
<PpTimelineCard
v-for="card in timeline"
v-bind="card"
/> />
</div> </div>
</div>
</section> </section>
<PpToolbar>
<PpButton class="mini-button text-white transparent" @click="sort(currentSort)">
<Icon class="icon" name="uil:refresh" mode="svg" />
<span>Neu sortieren</span>
<span
class="dot"
:class="{ visible : isDirty}"
aria-hidden="true"
/>
</PpButton>
<PpButton class="mini-button text-white transparent" @click="openModal(true, -1)">
<Icon class="icon" name="uil:plus" mode="svg" />
<span>Hinzufügen</span>
</PpButton>
</PpToolbar>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Card } from '../../shared/Card' import type { TimelineCard } from '../../shared/TimelineCard'
import type { Button } from '../../shared/ButtonGroup'
import { PpPriceCardDialog, PpDeleteDialog } from '#components'
const currentSort = ref(0) definePageMeta({
const isDirty = ref(false) layout: 'landingpage'
const currentCard = ref<Card>()
const currentCardIndex = ref<number>(-1)
const modal = useTemplateRef<typeof PpPriceCardDialog>('modal')
const deleteModal = useTemplateRef<typeof PpDeleteDialog>('deleteModal')
const createCard = (uuid : string) : Card => ({
uuid,
name: '',
price: '',
roles: '',
sheets: '',
layers: '',
ppr: 0,
pps: 0,
ppl: 0,
}) })
const cards = useState<Card[]>('cards', () => [ const timeline : TimelineCard[] = [
createCard(crypto.randomUUID()),
])
const addCard = (card : Card) => {
const price = calculate(card)
cards.value.unshift({ ...card, ...price })
isDirty.value = true
updateLocalStorage()
}
const removeCard = (index : number) => {
cards.value.splice(index, 1)
isDirty.value = true
updateLocalStorage()
}
const updateCard = () => {
if (currentCardIndex.value === -1) {
addCard(currentCard.value!)
return
}
const price = calculate(currentCard.value!)
const newCard = { ...currentCard.value!, ...price }
cards.value.splice(currentCardIndex.value, 1, newCard)
isDirty.value = true
updateLocalStorage()
}
const openModal = (createNew : boolean, index : number) => {
if (createNew) {
currentCardIndex.value = -1
currentCard.value = createCard(crypto.randomUUID())
modal.value?.$el.showModal()
return
}
currentCardIndex.value = index
currentCard.value = { ...cards.value[index]! }
modal.value?.$el.showModal()
return
}
const openDeleteModal = () => {
deleteModal.value?.$el.showModal()
}
const updateLocalStorage = () => {
localStorage.setItem('cards', JSON.stringify(cards.value.map(card => {
const { uuid, name, price, roles, sheets, layers } = card
return { uuid, name, price, roles, sheets, layers }
})))
localStorage.setItem('sort', JSON.stringify(currentSort.value))
}
const filterButtons = ref<Button[]>([
{ {
label: 'Rollen', icon: 'uil:chart-bar',
icon: 'uil:toilet-paper', title: 'Mehr Vergleiche',
description: 'Zusätzliche Kategorien für Taschentücher und Küchenrolle',
state: {
value: 'inProgress',
}
}, },
{ {
label: 'Blatt', icon: 'uil:cloud-database-tree',
icon: 'uil:file-landscape', title: 'Datenbank',
description: 'Eine von der Community gestützte Datenbank mit Preisen für alle Produkte',
state: {
value: 'planned',
message: '2025',
}
}, },
{ {
label: 'Lagen', icon: 'uil:qrcode-scan',
icon: 'uil:layer-group', title: 'Barcode Scan',
}, description: 'Ganz einfach Barcode Scannen und Produkt direkt zum Rechner hinzufügen',
]) state: {
value: 'planned',
const sortBy = (key : 'ppr' | 'pps' | 'ppl') => { message: '2025',
cards.value.sort((a : Card, b : Card) => a[key] - b[key])
}
const sort = (index : number) => {
currentSort.value = index
filterButtons.value.forEach(button => { button.active = false })
filterButtons.value[index]!.active = true
switch (index) {
case 0:
sortBy('ppr')
break
case 1:
sortBy('pps')
break
case 2:
sortBy('ppl')
break
} }
},
updateLocalStorage() {
isDirty.value = false icon: 'uil:user',
} title: 'Optionale Accounts',
description: 'Zur Synchronisierung auf mehreren Geräten',
const calculate = (card : Card) => { state: {
const ppr = +replaceComma(card.price) / +card.roles value: 'planned',
const pps = (ppr / +card.sheets) * 100 }
const ppl = (pps / +card.layers) },
{
return { ppr, pps, ppl } icon: 'uil:cog',
} title: 'Personalisierung',
description: 'Persönliche Präferenzen zur Wortwahl, Standardsortierung und mehr',
onMounted(() => { state: {
const cardsFromStorage = JSON.parse(localStorage.getItem('cards') ?? '[]').map((card : Card) => ({ ...card, ...calculate(card) })) value: 'planned',
cards.value = cardsFromStorage.length !== 0 ? cardsFromStorage : cards.value }
const sortFromStorage = +JSON.parse(localStorage.getItem('sort') ?? '0') },
sort(sortFromStorage) {
filterButtons.value[sortFromStorage]!.active = true icon: 'uil:vector-square',
}) title: 'm² Preise',
description: 'Quadratmeterpreise für noch genauere Vergleiche',
state: {
value: 'planned',
}
},
]
</script> </script>

View file

@ -1,17 +1,53 @@
<template> <template>
<section class="flex-col gap-default content full"> <section class="Legal flex-col gap-default content full">
<h3> <h2>1) Einleitung und Kontaktdaten des Verantwortlichen</h2>
Datenschutzerklärung <p><b>1.1</b>&nbsp;Wir freuen uns, dass du unsere Website besuchst und bedanken uns f&uuml;r dein Interesse. Im Folgenden informieren wir dich &uuml;ber den Umgang mit deinen personenbezogenen Daten bei der Nutzung unserer Website. Personenbezogene Daten sind hierbei alle Daten, mit denen du pers&ouml;nlich identifiziert werden kannst.</p>
</h3> <p><b>1.2</b>&nbsp;Verantwortlicher f&uuml;r die Datenverarbeitung auf dieser Website im Sinne der Datenschutz-Grundverordnung (DSGVO) ist Fiona Lena Urban, Teichäckerweg 39, 76297 Stutensee , Tel.: 017631640961, E-Mail: fiona@webfussel.de. Der f&uuml;r die Verarbeitung von personenbezogenen Daten Verantwortliche ist diejenige nat&uuml;rliche oder juristische Person, die allein oder gemeinsam mit anderen &uuml;ber die Zwecke und Mittel der Verarbeitung von personenbezogenen Daten entscheidet.</p>
<p> <h2>2) Datenerfassung beim Besuch unserer Website</h2>
Wir senden keinerlei Daten an Drittanbieter. <p><b>2.1</b>&nbsp;Bei der blo&szlig; informatorischen Nutzung unserer Website, also wenn du dich nicht registrierst oder uns anderweitig Informationen &uuml;bermittelst, erheben wir nur solche Daten, die dein Browser an den Seitenserver &uuml;bermittelt (sog. &bdquo;Server-Logfiles&#8220;). Wenn du unsere Website aufrufst, erheben wir die folgenden Daten, die f&uuml;r uns technisch erforderlich sind, um dir die Website anzuzeigen:</p>
</p> <ul>
<p> <li>Unsere besuchte Website</li>
Wir verwerten keinerlei Daten. <li>Datum und Uhrzeit zum Zeitpunkt des Zugriffs</li>
</p> <li>Menge der gesendeten Daten in Byte</li>
<p> <li>Quelle/Verweis, von welchem du auf die Seite gelangtest</li>
Alle persistierten Daten befinden sich ausschließlich auf Ihrem Endgerät im sogenannten "localStorage" und werden ausschließlich auf Ihrem Gerät verarbeitet. <li>Verwendeter Browser</li>
</p> <li>Verwendetes Betriebssystem</li>
<li>Verwendete IP-Adresse (ggf.: in anonymisierter Form)</li>
</ul>
<p>Die Verarbeitung erfolgt gem&auml;&szlig; Art. 6 Abs. 1 lit. f DSGVO auf Basis unseres berechtigten Interesses an der Verbesserung der Stabilit&auml;t und Funktionalit&auml;t unserer Website. Eine Weitergabe oder anderweitige Verwendung der Daten findet nicht statt. Wir behalten uns allerdings vor, die Server-Logfiles nachtr&auml;glich zu &uuml;berpr&uuml;fen, sollten konkrete Anhaltspunkte auf eine rechtswidrige Nutzung hinweisen.</p>
<p><b>2.2</b>&nbsp;Diese Website nutzt aus Sicherheitsgr&uuml;nden und zum Schutz der &Uuml;bertragung personenbezogener Daten und anderer vertraulicher Inhalte (z.B. Bestellungen oder Anfragen an uns) eine SSL-bzw. TLS-Verschl&uuml;sselung. Du kannst eine verschl&uuml;sselte Verbindung an der Zeichenfolge &bdquo;https://&#8220; und dem Schloss-Symbol in deiner Browserzeile erkennen.</p>
<h2>3) Kontaktaufnahme</h2>
<p>Im Rahmen der Kontaktaufnahme mit uns (z.B. per Kontaktformular oder E-Mail) werden &ndash; ausschlie&szlig;lich zum Zweck der Bearbeitung und Beantwortung deines Anliegens und nur im daf&uuml;r erforderlichen Umfang &ndash; personenbezogene Daten verarbeitet.</p>
<p>Rechtsgrundlage f&uuml;r die Verarbeitung dieser Daten ist unser berechtigtes Interesse an der Beantwortung deines Anliegens gem&auml;&szlig; Art. 6 Abs. 1 lit. f DSGVO. Zielt deine Kontaktierung auf einen Vertrag ab, so ist zus&auml;tzliche Rechtsgrundlage f&uuml;r die Verarbeitung Art. 6 Abs. 1 lit. b DSGVO. Deine Daten werden gel&ouml;scht, wenn sich aus den Umst&auml;nden entnehmen l&auml;sst, dass der betroffene Sachverhalt abschlie&szlig;end gekl&auml;rt ist und sofern keine gesetzlichen Aufbewahrungspflichten entgegenstehen.</p>
<h2>4) Webanalysedienste</h2>
<p>Plausible</p>
<p>Diese Website nutzt &bdquo;Plausible&#8220;, ein Webanalyse-Tool der Firma Plausible Insights O&Uuml; V&auml;striku tn 2, 50403, Tartu, Estland.</p>
<p>Es werden damit Interaktionen von zuf&auml;llig ausgew&auml;hlten, einzelnen Besuchern mit der Internetseite anonymisiert aufgezeichnet. So entsteht ein Protokoll von z.B. Mausbewegungen und -Klicks mit dem Ziel, Verbesserungsm&ouml;glichkeiten der jeweiligen Internetseite aufzuzeigen. Zu keinem Zeitpunkt werden personenbezogene Daten erhoben oder verarbeitet. Plausible erhebt bei der Nutzung dieser Internetseite ausschlie&szlig;lich nicht personenbezogene Daten wie Informationen zum Browser und zum User Agent. Diese werden in nicht personenbeziehbarer Form gespeichert und zu statistischen Zwecken ausgewertet. Eine L&ouml;schung findet statt, sobald die Daten f&uuml;r unsere Auswertungszwecke nicht mehr ben&ouml;tigt werden.</p>
<p>Sofern im Einzelfall doch personenbezogene Daten verarbeitet werden, erfolgt die Verarbeitung auf Basis unseres berechtigten Interesses an der statistischen Auswertung des Nutzungsverhaltens zu Optimierungszwecken gem&auml;&szlig; Art. 6 Abs. 1 lit. f DSGVO.</p>
<h2>5) Rechte des Betroffenen</h2>
<p><b>5.1</b>&nbsp;Das geltende Datenschutzrecht gew&auml;hrt dir gegen&uuml;ber uns als Verantwortlichen hinsichtlich der Verarbeitung deiner personenbezogenen Daten die nachstehenden Betroffenenrechte (Auskunfts- und Interventionsrechte), wobei f&uuml;r die jeweiligen Aus&uuml;bungsvoraussetzungen auf die angef&uuml;hrte Rechtsgrundlage verwiesen wird:</p>
<ul>
<li>Auskunftsrecht gem&auml;&szlig; Art. 15 DSGVO;</li>
<li>Recht auf Berichtigung gem&auml;&szlig; Art. 16 DSGVO;</li>
<li>Recht auf L&ouml;schung gem&auml;&szlig; Art. 17 DSGVO;</li>
<li>Recht auf Einschr&auml;nkung der Verarbeitung gem&auml;&szlig; Art. 18 DSGVO;</li>
<li>Recht auf Unterrichtung gem&auml;&szlig; Art. 19 DSGVO;</li>
<li>Recht auf Daten&uuml;bertragbarkeit gem&auml;&szlig; Art. 20 DSGVO;</li>
<li>Recht auf Widerruf erteilter Einwilligungen gem&auml;&szlig; Art. 7 Abs. 3 DSGVO;</li>
<li>Recht auf Beschwerde gem&auml;&szlig; Art. 77 DSGVO.</li>
</ul>
<p><b>5.2</b>&nbsp;WIDERSPRUCHSRECHT</p>
<p>WENN WIR IM RAHMEN EINER INTERESSENABW&Auml;GUNG DEINE PERSONENBEZOGENEN DATEN AUFGRUND UNSERES &Uuml;BERWIEGENDEN BERECHTIGTEN INTERESSES VERARBEITEN, HAST DU DAS JEDERZEITIGE RECHT, AUS GR&Uuml;NDEN, DIE SICH AUS DEINER BESONDEREN SITUATION ERGEBEN, GEGEN DIESE VERARBEITUNG WIDERSPRUCH MIT WIRKUNG F&Uuml;R DIE ZUKUNFT EINZULEGEN.</p>
<p>MACHST DU VON DEINEM WIDERSPRUCHSRECHT GEBRAUCH, BEENDEN WIR DIE VERARBEITUNG DER BETROFFENEN DATEN. EINE WEITERVERARBEITUNG BLEIBT ABER VORBEHALTEN, WENN WIR ZWINGENDE SCHUTZW&Uuml;RDIGE GR&Uuml;NDE F&Uuml;R DIE VERARBEITUNG NACHWEISEN K&Ouml;NNEN, DIE DEINE INTERESSEN, GRUNDRECHTE UND GRUNDFREIHEITEN &Uuml;BERWIEGEN, ODER WENN DIE VERARBEITUNG DER GELTENDMACHUNG, AUS&Uuml;BUNG ODER VERTEIDIGUNG VON RECHTSANSPR&Uuml;CHEN DIENT.</p>
<p>WERDEN DEINE PERSONENBEZOGENEN DATEN VON UNS VERARBEITET, UM DIREKTWERBUNG ZU BETREIBEN, HAST DU DAS RECHT, JEDERZEIT WIDERSPRUCH GEGEN DIE VERARBEITUNG DIR BETREFFENDER PERSONENBEZOGENER DATEN ZUM ZWECKE DERARTIGER WERBUNG EINZULEGEN. DU KANNST DEN WIDERSPRUCH WIE OBEN BESCHRIEBEN AUS&Uuml;BEN.</p>
<p>MACHST DU VON DEINEM WIDERSPRUCHSRECHT GEBRAUCH, BEENDEN WIR DIE VERARBEITUNG DER BETROFFENEN DATEN ZU DIREKTWERBEZWECKEN.</p>
<h2>6) Dauer der Speicherung personenbezogener Daten</h2>
<p>Die Dauer der Speicherung von personenbezogenen Daten bemisst sich anhand der jeweiligen Rechtsgrundlage, am Verarbeitungszweck und &ndash; sofern einschl&auml;gig &ndash; zus&auml;tzlich anhand der jeweiligen gesetzlichen Aufbewahrungsfrist (z.B. handels- und steuerrechtliche Aufbewahrungsfristen).</p>
<p>Bei der Verarbeitung von personenbezogenen Daten auf Grundlage einer ausdr&uuml;cklichen Einwilligung gem&auml;&szlig; Art. 6 Abs. 1 lit. a DSGVO werden die betroffenen Daten so lange gespeichert, bis du deine Einwilligung widerrufst.</p>
<p>Existieren gesetzliche Aufbewahrungsfristen f&uuml;r Daten, die im Rahmen rechtsgesch&auml;ftlicher bzw. rechtsgesch&auml;fts&auml;hnlicher Verpflichtungen auf der Grundlage von Art. 6 Abs. 1 lit. b DSGVO verarbeitet werden, werden diese Daten nach Ablauf der Aufbewahrungsfristen routinem&auml;&szlig;ig gel&ouml;scht, sofern sie nicht mehr zur Vertragserf&uuml;llung oder Vertragsanbahnung erforderlich sind und/oder unsererseits kein berechtigtes Interesse an der Weiterspeicherung fortbesteht.</p>
<p>Bei der Verarbeitung von personenbezogenen Daten auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO werden diese Daten so lange gespeichert, bis du dein Widerspruchsrecht nach Art. 21 Abs. 1 DSGVO aus&uuml;bst, es sei denn, wir k&ouml;nnen zwingende schutzw&uuml;rdige Gr&uuml;nde f&uuml;r die Verarbeitung nachweisen, die deine Interessen, Rechte und Freiheiten &uuml;berwiegen, oder die Verarbeitung dient der Geltendmachung, Aus&uuml;bung oder Verteidigung von Rechtsanspr&uuml;chen.</p>
<p>Bei der Verarbeitung von personenbezogenen Daten zum Zwecke der Direktwerbung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO werden diese Daten so lange gespeichert, bis du dein Widerspruchsrecht nach Art. 21 Abs. 2 DSGVO aus&uuml;bst.</p>
<p>Sofern sich aus den sonstigen Informationen dieser Erkl&auml;rung &uuml;ber spezifische Verarbeitungssituationen nichts anderes ergibt, werden gespeicherte personenbezogene Daten im &Uuml;brigen dann gel&ouml;scht, wenn sie f&uuml;r die Zwecke, f&uuml;r die sie erhoben oder auf sonstige Weise verarbeitet wurden, nicht mehr notwendig sind.</p>
</section> </section>
</template> </template>

151
app/pages/rechner.vue Executable file
View file

@ -0,0 +1,151 @@
<template>
<div class="nuxt-page-wrapper flex-col">
<ClientOnly>
<PpDeleteDialog
ref="deleteModal"
:current-card-index="currentCardIndex"
@delete="removeCard(currentCardIndex)"
/>
<PpPriceCardDialog
ref="modal"
:current-card="currentCard"
:current-card-index="currentCardIndex"
@update="updateCard()"
/>
<section class="content flex-col">
<aside class="filter-bar">
<button v-for="(button, index) in filterButtons" @click="() => sort(index)" :class="{ 'active': button.active }">
{{ button.label }}
</button>
</aside>
<div class="flex-col" role="list" v-if="cards.length">
<PpPriceCard
ref="priceCard"
v-for="(card, index) in cards"
:key="card.uuid"
:deletable="cards.length > 1"
:card="card"
@update="openModal(false, index)"
@remove="openDeleteModal()"
/>
</div>
<p class="info-text grow" v-else>
Du hast noch keinerlei Einträge angelegt.
<br />Aber das ist gar nicht schlimm!
<br />Tippe einfach unten auf "+ Hinzufügen" und leg los.
</p>
</section>
<PpToolbar>
<PpButton class="mini-button text-white transparent" @click="openModal(true, -1)">
<Icon class="icon" name="uil:plus" mode="svg" />
<span>Hinzufügen</span>
</PpButton>
</PpToolbar>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import type { PriceCard } from '../../shared/PriceCard'
import type { Button } from '../../shared/ButtonGroup'
import { PpPriceCardDialog, PpDeleteDialog, PpPriceCard } from '#components'
const cards = useLocalStorage<PriceCard[]>('cards', [])
const currentSort = useLocalStorage<number>('sort', 0)
const currentCard = ref<PriceCard>()
const currentCardIndex = ref<number>(-1)
const modal = useTemplateRef<typeof PpPriceCardDialog>('modal')
const deleteModal = useTemplateRef<typeof PpDeleteDialog>('deleteModal')
const priceCards = useTemplateRef<(typeof PpPriceCard)[]>('priceCard')
const createCard = (uuid : string) : PriceCard => ({
uuid,
name: '',
price: '',
roles: '',
sheets: '',
layers: '',
})
const addCard = (card : PriceCard) => {
cards.value.unshift({ ...card })
sort()
}
const removeCard = (index : number) => {
cards.value.splice(index, 1)
sort()
}
const updateCard = () => {
if (currentCardIndex.value === -1) {
addCard(currentCard.value!)
return
}
const newCard = { ...currentCard.value! }
cards.value.splice(currentCardIndex.value, 1, newCard)
sort()
}
const openModal = (createNew : boolean, index : number) => {
if (createNew) {
currentCardIndex.value = -1
currentCard.value = createCard(randomUUID())
modal.value?.$el.showModal()
return
}
currentCardIndex.value = index
currentCard.value = { ...cards.value[index]! }
modal.value?.$el.showModal()
return
}
const openDeleteModal = () => {
deleteModal.value?.$el.showModal()
}
const filterButtons = ref<Button[]>([
{
label: 'Rollen',
icon: 'uil:toilet-paper',
active: currentSort.value === 0,
},
{
label: 'Blatt',
icon: 'uil:file-landscape',
active: currentSort.value === 1,
},
{
label: 'Lagen',
icon: 'uil:layer-group',
active: currentSort.value === 2,
},
])
const sortBy = (key : 'ppr' | 'pps' | 'ppl') => {
cards.value.sort((a, b) => {
const aCard = priceCards.value?.find(card => card.uuid === a.uuid) || null
const bCard = priceCards.value?.find(card => card.uuid === b.uuid) || null
if (!aCard || !bCard) return 0
return aCard[key] - bCard[key]
})
}
const sort = async (index : number = currentSort.value) => {
currentSort.value = index
filterButtons.value.forEach(button => { button.active = false })
filterButtons.value[index]!.active = true
await nextTick()
switch (index) {
case 0: return sortBy('ppr')
case 1: return sortBy('pps')
case 2: return sortBy('ppl')
}
}
</script>

View file

@ -1,3 +1,5 @@
export const replaceComma = (value: string | number) => `${value}`.replace(',', '.') export const replaceComma = (value: string | number) => `${value}`.replace(',', '.')
export const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max) export const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
export const between = (value: number, min: number, max: number) => value >= min && value <= max

7
app/utils/uuid.ts Normal file
View file

@ -0,0 +1,7 @@
export const randomUUID = (): string => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
})
}

47
docker-compose.yaml Normal file
View file

@ -0,0 +1,47 @@
version: '3'
services:
propapier:
image: oven/bun:latest
container_name: propapier
working_dir: /app
ports:
- "1338:3000"
volumes:
- propapier_data:/app
environment:
- NODE_ENV=production
command: >
sh -c "
# Install git and curl if not already installed
if ! command -v git &> /dev/null || ! command -v curl &> /dev/null; then
echo 'Installing required packages...'
apt-get update && apt-get install -y git curl
fi &&
# Clone repository if not already cloned
if [ ! -d /app/.git ]; then
echo 'Cloning repository...'
git clone https://git.webfussel.de/webfussel/propapier /tmp/propapier &&
cp -r /tmp/propapier/. /app/ &&
rm -rf /tmp/propapier
fi &&
# Install dependencies and start application
echo 'Installing dependencies...' &&
bun install &&
echo 'Building application...' &&
bun run build &&
echo 'Starting application...' &&
bun .output/server/index.mjs
"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
propapier_data:

View file

@ -1,4 +1,5 @@
// https://nuxt.com/docs/api/configuration/nuxt-config const description = 'Du zahlst zuviel für\'s Papier? Vergleiche schnell und unkompliziert die Preise für Toiletten-, Küchen- und andere Papier hier.'
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2024-11-01', compatibilityDate: '2024-11-01',
devtools: { enabled: false }, devtools: { enabled: false },
@ -16,22 +17,95 @@ export default defineNuxtConfig({
} }
}, },
app: {
pageTransition: {
name: 'page',
mode: 'out-in',
},
head: {
htmlAttrs: { lang: 'de' },
link: [
{ rel: 'preload', as: 'image', href: '/img/propapier.svg', type: 'image/svg+xml' },
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' },
],
}
},
routeRules: { routeRules: {
'/': { prerender: true }, '/': { prerender: true },
'/imprint': { prerender: true }, '/imprint': { prerender: true },
'/privacy': { prerender: true }, '/privacy': { prerender: true },
}, },
modules: ['@nuxt/icon', '@vueuse/nuxt', '@nuxtjs/device'], modules: [
'@nuxt/icon',
'@vueuse/nuxt',
'@nuxtjs/device',
'@nuxt/fonts',
'nuxt-seo-utils',
'nuxt-ripple',
'@nuxtjs/sitemap',
'@nuxtjs/robots',
'@nuxtjs/plausible'
],
css : [ css : [
'./app/assets/styles/general.css', './app/assets/styles/general.css',
'./app/assets/styles/header.css', './app/assets/styles/header.css',
'./app/assets/styles/navigation.css',
'./app/assets/styles/footer.css', './app/assets/styles/footer.css',
'./app/assets/styles/button.css', './app/assets/styles/button.css',
'./app/assets/styles/buttonGroup.css', './app/assets/styles/buttonGroup.css',
'./app/assets/styles/priceCard.css', './app/assets/styles/priceCard.css',
'./app/assets/styles/formInput.css', './app/assets/styles/timelineCard.css',
'./app/assets/styles/form/textfield.css',
'./app/assets/styles/form/search.css',
'./app/assets/styles/toolbar.css', './app/assets/styles/toolbar.css',
] './app/assets/styles/page.css',
'./app/assets/styles/dialog.css',
'./app/assets/styles/landingpage.css',
],
site: {
url: 'https://pro-papier.de',
name: 'ProPapier',
},
seo: {
meta: {
title: 'ProPapier',
description,
themeColor: [
{ content: '#18181b', media: '(prefers-color-scheme: dark)' },
{ content: 'white', media: '(prefers-color-scheme: light)' },
],
twitterCreator: '@webfussel',
twitterSite: '@propapier',
author: 'webfussel',
colorScheme: 'dark light',
applicationName: 'ProPapier',
// Nuxt SEO Utils already sets the below tags for you
ogSiteName: 'ProPapier',
ogLocale: 'de_DE',
ogType: 'website',
ogUrl: 'https://pro-papier.de',
ogTitle: 'ProPapier',
ogDescription: description,
// Other Nuxt SEO modules handles these
ogImage: '/img/og.png',
robots: 'index, follow',
}
},
sitemap: {
// exclude all URLs that start with /secret
exclude: ['/other/**'],
},
plausible: {
// Prevent tracking on localhost
ignoredHostnames: ['localhost'],
},
}) })

1519
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,17 +7,23 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"dev:expose": "nuxt dev --host", "dev:expose": "nuxt dev --host",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "npx serve .output/public",
"prepare": "nuxt prepare", "prepare": "nuxt prepare",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"@iconify-json/simple-icons": "^1.2.32", "@iconify-json/simple-icons": "^1.2.32",
"@iconify-json/uil": "^1.2.3", "@iconify-json/uil": "^1.2.3",
"@nuxt/fonts": "^0.11.3",
"@nuxt/icon": "^1.10.3", "@nuxt/icon": "^1.10.3",
"@nuxtjs/device": "^3.2.4", "@nuxtjs/device": "^3.2.4",
"@nuxtjs/plausible": "^1.2.0",
"@nuxtjs/robots": "^5.2.10",
"@nuxtjs/sitemap": "^7.3.0",
"@vueuse/nuxt": "^13.1.0", "@vueuse/nuxt": "^13.1.0",
"nuxt": "^3.16.2", "nuxt": "^3.16.2",
"nuxt-ripple": "^0.0.8",
"nuxt-seo-utils": "^7.0.11",
"vue": "latest", "vue": "latest",
"vue-router": "latest" "vue-router": "latest"
} }

Binary file not shown.

Before

Width: 32px  |  Height: 32px  |  Size: 4.2 KiB

1
public/favicon.svg Executable file
View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1835 1957" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g><path d="M1567.66,18.116c145.891,-0 264.336,225.821 264.336,503.97c-0,278.149 -118.445,503.971 -264.336,503.971c-145.891,-0 -264.336,-225.822 -264.336,-503.971c0,-278.149 118.445,-503.97 264.336,-503.97Zm-0,224.151c-68.571,-0 -124.242,125.383 -124.242,279.819c0,154.437 55.671,279.82 124.242,279.82c68.571,0 124.241,-125.383 124.241,-279.82c0,-154.436 -55.67,-279.819 -124.241,-279.819Z" style="fill:#fff;"/><path d="M1303.43,755.105c13.811,85.466 89.537,243.344 171.549,272.195l-167.42,-1.213l-4.129,-270.982Z" style="fill:#fff;"/><path d="M9.052,1835.28l1.152,-1300.71c4.727,-203.082 60.44,-510.777 379.038,-525.353l1070.29,5.76c-106.16,85.364 -183.364,230.766 -179.726,543.787l0,1309.93c0,94.917 -24.936,111.898 -116.361,20.738c-67.285,-67.09 -128.514,-68.942 -195.856,-2.304c-80.33,79.49 -145.126,81.518 -216.593,2.304c-64.824,-71.852 -138.451,-64.497 -203.92,-0c-74.453,73.348 -133.367,79.706 -208.528,-4.609c-59.394,-66.627 -136.107,-49.149 -208.528,9.217c-95.361,76.853 -120.97,91.489 -120.97,-58.757Zm265.074,-1341.6c-0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.181,2.321 -5.181,5.18c0,2.859 2.322,5.181 5.181,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Zm138.149,624.213l0,79.687l-84.961,0l0,72.657l85.037,-0c0.756,52.26 7.176,98.257 19.26,137.988c12.891,42.383 31.25,77.734 55.078,106.055c23.828,28.32 51.953,49.609 84.375,63.867c32.422,14.258 68.164,21.386 107.227,21.386c20.703,0 41.211,-1.562 61.523,-4.687c20.313,-3.125 39.844,-7.422 58.594,-12.891l-11.719,-93.75c-16.406,6.25 -33.691,11.133 -51.855,14.649c-18.164,3.515 -36.621,5.273 -55.371,5.273c-24.219,0 -46.094,-5.078 -65.625,-15.234c-19.532,-10.156 -36.328,-25.391 -50.391,-45.703c-14.062,-20.313 -24.805,-46.094 -32.227,-77.344c-6.796,-28.617 -10.481,-61.82 -11.053,-99.609l182.147,-0l0,-72.657l-182.226,0l-0,-79.687l182.226,-0l0,-73.242l-181.972,-0c0.952,-34.41 4.578,-64.879 10.878,-91.407c7.422,-31.25 17.969,-57.031 31.641,-77.343c13.672,-20.313 30.274,-35.547 49.805,-45.704c19.531,-10.156 41.601,-15.234 66.211,-15.234c19.14,0 37.793,1.856 55.957,5.567c18.164,3.71 35.449,8.691 51.855,14.941l11.719,-94.336c-20.313,-5.469 -40.234,-9.863 -59.766,-13.184c-19.531,-3.32 -39.843,-4.98 -60.937,-4.98c-39.063,-0 -74.903,7.129 -107.52,21.387c-32.617,14.257 -60.644,35.644 -84.082,64.16c-23.437,28.515 -41.601,64.062 -54.492,106.64c-11.367,37.545 -17.722,80.709 -19.066,129.493l-85.231,-0l0,73.242l84.961,-0Zm403.587,-624.213c0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.18,2.321 -5.18,5.18c0,2.859 2.321,5.181 5.18,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Zm-272.284,0c0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.18,2.321 -5.18,5.18c0,2.859 2.321,5.181 5.18,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Zm541.737,0c0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.18,2.321 -5.18,5.18c-0,2.859 2.321,5.181 5.18,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Z" style="fill:#fff;"/></g></svg>

After

(image error) Size: 3.3 KiB

View file

@ -0,0 +1 @@
<svg id="visual" viewBox="0 0 3840 2160" width="3840" height="2160" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"><rect x="0" y="0" width="3840" height="2160" fill="#292929"></rect><defs><linearGradient id="grad1_0" x1="43.8%" y1="100%" x2="100%" y2="0%"><stop offset="14.444444444444446%" stop-color="#013174" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#013174" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad1_1" x1="43.8%" y1="100%" x2="100%" y2="0%"><stop offset="14.444444444444446%" stop-color="#013174" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#202e5a" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad1_2" x1="43.8%" y1="100%" x2="100%" y2="0%"><stop offset="14.444444444444446%" stop-color="#282b41" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#202e5a" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad1_3" x1="43.8%" y1="100%" x2="100%" y2="0%"><stop offset="14.444444444444446%" stop-color="#282b41" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#292929" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad2_0" x1="0%" y1="100%" x2="56.3%" y2="0%"><stop offset="14.444444444444446%" stop-color="#013174" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#013174" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad2_1" x1="0%" y1="100%" x2="56.3%" y2="0%"><stop offset="14.444444444444446%" stop-color="#202e5a" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#013174" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad2_2" x1="0%" y1="100%" x2="56.3%" y2="0%"><stop offset="14.444444444444446%" stop-color="#202e5a" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#282b41" stop-opacity="1"></stop></linearGradient></defs><defs><linearGradient id="grad2_3" x1="0%" y1="100%" x2="56.3%" y2="0%"><stop offset="14.444444444444446%" stop-color="#292929" stop-opacity="1"></stop><stop offset="85.55555555555554%" stop-color="#282b41" stop-opacity="1"></stop></linearGradient></defs><g transform="translate(3840, 2160)"><path d="M-1956 0C-1926.7 -253.3 -1897.4 -506.6 -1820 -753.9C-1742.7 -1001.2 -1617.2 -1242.4 -1438.3 -1438.3C-1259.3 -1634.1 -1027 -1784.5 -778.4 -1879.2C-529.8 -1973.9 -264.9 -2012.9 0 -2052L0 0Z" fill="#292a35"></path><path d="M-1467 0C-1445 -190 -1423.1 -380 -1365 -565.4C-1307 -750.9 -1212.9 -931.8 -1078.7 -1078.7C-944.5 -1225.6 -770.2 -1338.3 -583.8 -1409.4C-397.3 -1480.4 -198.7 -1509.7 0 -1539L0 0Z" fill="#252d4d"></path><path d="M-978 0C-963.4 -126.7 -948.7 -253.3 -910 -376.9C-871.3 -500.6 -808.6 -621.2 -719.1 -719.1C-629.7 -817 -513.5 -892.2 -389.2 -939.6C-264.9 -986.9 -132.4 -1006.5 0 -1026L0 0Z" fill="#172f67"></path><path d="M-489 0C-481.7 -63.3 -474.4 -126.7 -455 -188.5C-435.7 -250.3 -404.3 -310.6 -359.6 -359.6C-314.8 -408.5 -256.7 -446.1 -194.6 -469.8C-132.4 -493.5 -66.2 -503.2 0 -513L0 0Z" fill="#013174"></path></g><g transform="translate(0, 0)"><path d="M1962 0C1948.3 263.6 1934.6 527.1 1846.8 765C1759 1002.9 1597.1 1215.1 1413.5 1413.5C1229.9 1612 1024.5 1796.7 785.3 1895.8C546.1 1994.9 273 2008.5 0 2022L0 0Z" fill="#292a35"></path><path d="M1471.5 0C1461.2 197.7 1451 395.3 1385.1 573.7C1319.3 752.1 1197.9 911.3 1060.1 1060.1C922.4 1209 768.4 1347.5 588.9 1421.9C409.5 1496.2 204.8 1506.3 0 1516.5L0 0Z" fill="#252d4d"></path><path d="M981 0C974.2 131.8 967.3 263.6 923.4 382.5C879.5 501.4 798.6 607.5 706.8 706.8C614.9 806 512.2 898.3 392.6 947.9C273 997.5 136.5 1004.2 0 1011L0 0Z" fill="#172f67"></path><path d="M490.5 0C487.1 65.9 483.7 131.8 461.7 191.2C439.8 250.7 399.3 303.8 353.4 353.4C307.5 403 256.1 449.2 196.3 474C136.5 498.7 68.3 502.1 0 505.5L0 0Z" fill="#013174"></path></g></svg>

After

(image error) Size: 3.8 KiB

BIN
public/img/hero-image.webp Normal file

Binary file not shown.

After

(image error) Size: 26 KiB

BIN
public/img/og.png Executable file

Binary file not shown.

After

(image error) Size: 384 KiB

1
public/img/propapier.svg Executable file
View file

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1835 1957" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g><path d="M1567.66,18.116c145.891,-0 264.336,225.821 264.336,503.97c-0,278.149 -118.445,503.971 -264.336,503.971c-145.891,-0 -264.336,-225.822 -264.336,-503.971c0,-278.149 118.445,-503.97 264.336,-503.97Zm-0,224.151c-68.571,-0 -124.242,125.383 -124.242,279.819c0,154.437 55.671,279.82 124.242,279.82c68.571,0 124.241,-125.383 124.241,-279.82c0,-154.436 -55.67,-279.819 -124.241,-279.819Z" style="fill:#fff;"/><path d="M1303.43,755.105c13.811,85.466 89.537,243.344 171.549,272.195l-167.42,-1.213l-4.129,-270.982Z" style="fill:#fff;"/><path d="M9.052,1835.28l1.152,-1300.71c4.727,-203.082 60.44,-510.777 379.038,-525.353l1070.29,5.76c-106.16,85.364 -183.364,230.766 -179.726,543.787l0,1309.93c0,94.917 -24.936,111.898 -116.361,20.738c-67.285,-67.09 -128.514,-68.942 -195.856,-2.304c-80.33,79.49 -145.126,81.518 -216.593,2.304c-64.824,-71.852 -138.451,-64.497 -203.92,-0c-74.453,73.348 -133.367,79.706 -208.528,-4.609c-59.394,-66.627 -136.107,-49.149 -208.528,9.217c-95.361,76.853 -120.97,91.489 -120.97,-58.757Zm265.074,-1341.6c-0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.181,2.321 -5.181,5.18c0,2.859 2.322,5.181 5.181,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Zm138.149,624.213l0,79.687l-84.961,0l0,72.657l85.037,-0c0.756,52.26 7.176,98.257 19.26,137.988c12.891,42.383 31.25,77.734 55.078,106.055c23.828,28.32 51.953,49.609 84.375,63.867c32.422,14.258 68.164,21.386 107.227,21.386c20.703,0 41.211,-1.562 61.523,-4.687c20.313,-3.125 39.844,-7.422 58.594,-12.891l-11.719,-93.75c-16.406,6.25 -33.691,11.133 -51.855,14.649c-18.164,3.515 -36.621,5.273 -55.371,5.273c-24.219,0 -46.094,-5.078 -65.625,-15.234c-19.532,-10.156 -36.328,-25.391 -50.391,-45.703c-14.062,-20.313 -24.805,-46.094 -32.227,-77.344c-6.796,-28.617 -10.481,-61.82 -11.053,-99.609l182.147,-0l0,-72.657l-182.226,0l-0,-79.687l182.226,-0l0,-73.242l-181.972,-0c0.952,-34.41 4.578,-64.879 10.878,-91.407c7.422,-31.25 17.969,-57.031 31.641,-77.343c13.672,-20.313 30.274,-35.547 49.805,-45.704c19.531,-10.156 41.601,-15.234 66.211,-15.234c19.14,0 37.793,1.856 55.957,5.567c18.164,3.71 35.449,8.691 51.855,14.941l11.719,-94.336c-20.313,-5.469 -40.234,-9.863 -59.766,-13.184c-19.531,-3.32 -39.843,-4.98 -60.937,-4.98c-39.063,-0 -74.903,7.129 -107.52,21.387c-32.617,14.257 -60.644,35.644 -84.082,64.16c-23.437,28.515 -41.601,64.062 -54.492,106.64c-11.367,37.545 -17.722,80.709 -19.066,129.493l-85.231,-0l0,73.242l84.961,-0Zm403.587,-624.213c0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.18,2.321 -5.18,5.18c0,2.859 2.321,5.181 5.18,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Zm-272.284,0c0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.18,2.321 -5.18,5.18c0,2.859 2.321,5.181 5.18,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Zm541.737,0c0,-2.859 -2.321,-5.18 -5.18,-5.18l-132.364,0c-2.859,0 -5.18,2.321 -5.18,5.18c-0,2.859 2.321,5.181 5.18,5.181l132.364,-0c2.859,-0 5.18,-2.322 5.18,-5.181Z" style="fill:#fff;"/></g></svg>

After

(image error) Size: 3.3 KiB

1
server/api/health.ts Normal file
View file

@ -0,0 +1 @@
export default defineEventHandler((): string => 'ok' )

View file

@ -1,11 +0,0 @@
export type Card = {
ppr: number
pps: number
ppl: number
uuid: string
name: string
price: string
roles: string
sheets: string
layers: string
}

8
shared/PriceCard.ts Normal file
View file

@ -0,0 +1,8 @@
export type PriceCard = {
uuid : string
name : string
price : string
roles : string
sheets : string
layers : string
}

11
shared/TimelineCard.ts Normal file
View file

@ -0,0 +1,11 @@
export type TimelineState = 'planned' | 'inProgress' | 'done'
export type TimelineCard = {
icon: string
title: string
description: string
state: {
value: TimelineState
message ?: string
}
}