diff --git a/.env.example b/.env.example index 96d6793..37e5ef6 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,5 @@ -# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo. -# Keep this file up-to-date when you add new variables to `.env`. - -# This file will be committed to version control, so make sure not to have any secrets in it. -# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. - -# When adding additional env variables, the schema in /env/schema.mjs should be updated accordingly - -# Prisma -DATABASE_URL=file:./db.sqlite +DATABASE_URL=mysql://root:root@db/simple-meal-plan +INVITATION_VALIDITY=P30D +NEXTAUTH_SECRET=A_SECRET +ROOT_URL=https://example.com +NEXT_PUBLIC_PRIVACY_URL=https://example.com \ No newline at end of file diff --git a/README.md b/README.md index 8e4c910..175ed6f 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,27 @@ # Simple Meal Plan -This is a very simple meal planner which I build in two hours. -I want to replace the Excel Sheet, my girlfriend was using. -Currently there a no authentication build in. So this should only deployed on private networks. -Work in Progress. +This is a very simple meal planner. It's main purpose is to host it as a free SaaS solution. +You can use it [here](https://example.com). Anyway you can deploy it using Docker. + +I want to replace the Excel Sheet, my girlfriend was using. + +## Features + +![Example Screenshot](public/example.png) + +- Plan Meal for any date +- (Tablet, Desktop): See a complete calendar of the month which is editable +- Share meal plans with magic links to other users to collaborate (household, etc.) +- Manage multiple meal plans per user ## Techstack - [Next.js](https://nextjs.org) - [Prisma](https://prisma.io) - [Tailwind CSS](https://tailwindcss.com) -- [tRPC](https://trpc.io) +- [DaisyUI](https://daisyui.com) + +## Deployment -## Screenshot -![Example Screenshot](docs/example.png) \ No newline at end of file +You'll need a MySQL / MariaDB database. +Have a look at [`.env.example`](./.env.example) or at the [schema](./src/env/schema.mjs) diff --git a/docs/example.png b/docs/example.png deleted file mode 100644 index 78192f8..0000000 Binary files a/docs/example.png and /dev/null differ diff --git a/prisma/migrations/20240805194021_remove_userid_constrant/migration.sql b/prisma/migrations/20240805194021_remove_userid_constrant/migration.sql new file mode 100644 index 0000000..1aa04b1 --- /dev/null +++ b/prisma/migrations/20240805194021_remove_userid_constrant/migration.sql @@ -0,0 +1,2 @@ +-- DropConstraint +ALTER TABLE Account DROP KEY Account_userId_key; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 65fa36c..45fa328 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -59,7 +59,7 @@ model User { emailVerified DateTime? image String? Session Session[] - Account Account? + Account Account[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -70,7 +70,7 @@ model User { model Account { id String @id @default(cuid()) @db.Char(25) - userId String @unique @db.Char(25) + userId String @db.Char(25) type String provider String providerAccountId String diff --git a/public/example.png b/public/example.png new file mode 100644 index 0000000..dafdf60 Binary files /dev/null and b/public/example.png differ diff --git a/public/undraw_eating_together_re_ux62.svg b/public/undraw_eating_together_re_ux62.svg new file mode 100644 index 0000000..31fcbf0 --- /dev/null +++ b/public/undraw_eating_together_re_ux62.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/undraw_real_time_sync_re_nky7.svg b/public/undraw_real_time_sync_re_nky7.svg new file mode 100644 index 0000000..e1bd0e8 --- /dev/null +++ b/public/undraw_real_time_sync_re_nky7.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/[locale]/(landing)/FeatureBox.tsx b/src/app/[locale]/(landing)/FeatureBox.tsx new file mode 100644 index 0000000..2442ee9 --- /dev/null +++ b/src/app/[locale]/(landing)/FeatureBox.tsx @@ -0,0 +1,40 @@ +import Image from "next/image"; +import type { FC } from "react"; +import type { Feature } from "./Features"; +import { isImageFeature } from "./Features"; + +type Props = { + feature: Feature; +}; + +export const FeatureBox: FC = ({ feature }) => ( +
+ {isImageFeature(feature) && ( +
+ {feature.alt} +
+ )} +
+

{feature.heading}

+ {feature.body.map((paragraph, index) => ( +

{paragraph}

+ ))} + {feature.link && ( +
+ + More Infos + +
+ )} +
+
+); diff --git a/src/app/[locale]/(landing)/Features.ts b/src/app/[locale]/(landing)/Features.ts new file mode 100644 index 0000000..39e9ca9 --- /dev/null +++ b/src/app/[locale]/(landing)/Features.ts @@ -0,0 +1,63 @@ +// In this files we define the features of the landing page + +import { getScopedI18n } from "@/locales/server"; + +type Description = string; + +type PlainFeature = { + heading: Description; + body: Description[]; + link?: string; +}; + +type ImageFeature = PlainFeature & { + image: string; + alt: string; + width: number; + height: number; +}; + +export type Feature = PlainFeature | ImageFeature; + +export const getFeatures: () => Promise = async () => { + const t = await getScopedI18n("features"); + + return [ + { + heading: t("featureA"), + image: "/example.png", + alt: t("featureAImgAlt"), + width: 288, + height: 119, + body: [t("featureADescription")], + }, + { + heading: t("featureB"), + body: [t("featureBDescription1"), t("featureBDescription2")], + image: "undraw_real_time_sync_re_nky7.svg", + alt: t("featureBImgAlt"), + height: 200, + width: 200, + }, + { + heading: t("featureC"), + body: [t("featureCDescription")], + image: "undraw_eating_together_re_ux62.svg", + alt: t("featureCImgAlt"), + height: 200, + width: 200, + }, + { + heading: t("featureD"), + body: [t("featureDDescription")], + }, + { + heading: t("featureE"), + body: [t("featureEDescription")], + link: "https://github.com/timia2109/simple-meal-plan", + }, + ]; +}; + +export const isImageFeature = (feature: Feature): feature is ImageFeature => + "image" in feature; diff --git a/src/app/[locale]/(landing)/SignInButton.tsx b/src/app/[locale]/(landing)/SignInButton.tsx index 0c6f572..70d56c8 100644 --- a/src/app/[locale]/(landing)/SignInButton.tsx +++ b/src/app/[locale]/(landing)/SignInButton.tsx @@ -8,12 +8,10 @@ export const SignInButton: FC<{ id: string; label: string }> = ({ }) => { return ( ); }; diff --git a/src/app/[locale]/(landing)/SignInButtons.tsx b/src/app/[locale]/(landing)/SignInButtons.tsx index 0dbf83e..8f71fed 100644 --- a/src/app/[locale]/(landing)/SignInButtons.tsx +++ b/src/app/[locale]/(landing)/SignInButtons.tsx @@ -7,7 +7,7 @@ export async function SignInButtons() { const t = await getScopedI18n("landing"); return ( -
+
{Object.values(authConfig.providers as OAuth2Config[]).map( (d) => ( diff --git a/src/app/[locale]/(landing)/page.tsx b/src/app/[locale]/(landing)/page.tsx index 5320549..c6c936b 100644 --- a/src/app/[locale]/(landing)/page.tsx +++ b/src/app/[locale]/(landing)/page.tsx @@ -3,6 +3,8 @@ import { InvitationHeader } from "@/components/invitation/InvitationHeader"; import { getInvitation } from "@/dal/user/getInvitation"; import { getScopedI18n } from "@/locales/server"; import { redirectRoute } from "@/routes"; +import { FeatureBox } from "./FeatureBox"; +import { getFeatures } from "./Features"; import { SignInButtons } from "./SignInButtons"; type Props = { @@ -36,13 +38,16 @@ export default async function LandingPage({ searchParams }: Props) { ); if (currentUser != null && invitation == null) redirectRoute("mealPlan"); + const features = await getFeatures(); + return (
-
-
+ {t("title")} +
+

{t("welcome")} - + {" "} {t("title")} @@ -57,23 +62,9 @@ export default async function LandingPage({ searchParams }: Props) {
-
-
-

- Feature Heading -

-

Feature Box

-
-
- -
-
-

Search Params

-

- {JSON.stringify(searchParams)} -

-
-
+ {features.map((feature, index) => ( + + ))}

diff --git a/src/auth.config.ts b/src/auth.config.ts index 07818f4..18de614 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -1,7 +1,31 @@ import type { NextAuthConfig } from "next-auth"; -import GoogleProvider from "next-auth/providers/google"; +import { Provider } from "next-auth/providers"; +import Facebook from "next-auth/providers/facebook"; +import Google from "next-auth/providers/google"; import { env } from "./env/server.mjs"; +const providers: Provider[] = []; + +if (env.AUTH_GOOGLE_ID && env.AUTH_GOOGLE_SECRET) { + providers.push( + Google({ + clientId: env.AUTH_GOOGLE_ID, + clientSecret: env.AUTH_GOOGLE_SECRET, + allowDangerousEmailAccountLinking: env.ALLOW_ACCOUNT_LINKING === "true", + }) + ); +} + +if (env.AUTH_FACEBOOK_ID && env.AUTH_FACEBOOK_SECRET) { + providers.push( + Facebook({ + clientId: env.AUTH_FACEBOOK_ID, + clientSecret: env.AUTH_FACEBOOK_SECRET, + allowDangerousEmailAccountLinking: env.ALLOW_ACCOUNT_LINKING === "true", + }) + ); +} + export const authConfig: NextAuthConfig = { secret: env.NEXTAUTH_SECRET, session: { @@ -15,10 +39,5 @@ export const authConfig: NextAuthConfig = { return session; }, }, - providers: [ - GoogleProvider({ - clientId: env.GOOGLE_CLIENT_ID, - clientSecret: env.GOOGLE_CLIENT_SECRET, - }), - ], + providers, }; diff --git a/src/components/common/Footer.tsx b/src/components/common/Footer.tsx index 971332e..f0b1c65 100644 --- a/src/components/common/Footer.tsx +++ b/src/components/common/Footer.tsx @@ -18,14 +18,15 @@ export async function Footer() { href="https://timitt.dev" target="_blank" rel="noreferrer" - className="underline underline-offset-4 transition-all hover:font-bold - hover:text-orange-500" + className="link-hover link ms-1" > {t("author")}
{env.NEXT_PUBLIC_PRIVACY_URL && ( - {t("privacy")} + + {t("privacy")} + )} - {t("landing.title")} + + {t("landing.title")} +
diff --git a/src/components/mealPlan/MealPlanComponent.tsx b/src/components/mealPlan/MealPlanComponent.tsx index 5dab21d..40f2303 100644 --- a/src/components/mealPlan/MealPlanComponent.tsx +++ b/src/components/mealPlan/MealPlanComponent.tsx @@ -63,7 +63,7 @@ export async function MealPlanComponent({ )} -
+
{users.map((user) => ( diff --git a/src/env/schema.mjs b/src/env/schema.mjs index 26d117b..ed6ce3d 100644 --- a/src/env/schema.mjs +++ b/src/env/schema.mjs @@ -2,27 +2,43 @@ import { z } from "zod"; /** - * Specify your server-side environment variables schema here. - * This way you can ensure the app isn't built with invalid env vars. + * This are the environment variables for the server. + * You need to set them */ export const serverSchema = z.object({ - DATABASE_URL: z.string(), + DATABASE_URL: z.string().describe("The URL to the database"), NODE_ENV: z.enum(["development", "test", "production"]), - GOOGLE_CLIENT_SECRET: z.string(), - GOOGLE_CLIENT_ID: z.string(), - INVITATION_VALIDITY: z.string().default("P30D"), - SESSION_VALIDITY_IN_SECONDS: z.number().default(60 * 60 * 24 * 30), // 30 days - NEXTAUTH_SECRET: z.string(), - ROOT_URL: z.string().url().optional(), + INVITATION_VALIDITY: z + .string() + .default("P30D") + .describe("The duration of the invitation token"), + SESSION_VALIDITY_IN_SECONDS: z + .number() + .default(60 * 60 * 24 * 30) + .describe("Session validity"), // 30 days + NEXTAUTH_SECRET: z.string().describe("The secret for next-auth"), + ROOT_URL: z + .string() + .url() + .optional() + .describe("The root URL of the server. Used to generate invitation links"), + ALLOW_ACCOUNT_LINKING: z.enum(["true", "false"]).default("false"), + + // auth-js Providers + AUTH_GOOGLE_ID: z.string().optional(), + AUTH_GOOGLE_SECRET: z.string().optional(), + AUTH_FACEBOOK_ID: z.string().optional(), + AUTH_FACEBOOK_SECRET: z.string().optional(), }); /** - * Specify your client-side environment variables schema here. - * This way you can ensure the app isn't built with invalid env vars. - * To expose them to the client, prefix them with `NEXT_PUBLIC_`. + * This are the environment variables for the client. */ export const clientSchema = z.object({ - NEXT_PUBLIC_PRIVACY_URL: z.string().optional(), + NEXT_PUBLIC_PRIVACY_URL: z + .string() + .optional() + .describe("The URL to the privacy policy. Adds a point to the footer"), }); /** diff --git a/src/functions/user/onCreateUser.ts b/src/functions/user/onCreateUser.ts index c507082..1cee4ba 100644 --- a/src/functions/user/onCreateUser.ts +++ b/src/functions/user/onCreateUser.ts @@ -1,10 +1,13 @@ import type { User } from "next-auth"; +import { getMealPlans } from "@/dal/mealPlans/getMealPlans"; import { createMealPlan } from "../../dal/mealPlans/createMealPlan"; /** Creates a meal plan for the current user */ export const onCreateUser: (message: { user: User }) => Promise = async ({ user, }) => { - await createMealPlan(user.id!, "", true); + const mealPlanAssignments = await getMealPlans(user.id!); + + if (mealPlanAssignments.length == 0) await createMealPlan(user.id!, "", true); }; diff --git a/src/locales/de.ts b/src/locales/de.ts index 4b50864..b8e7b1e 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -4,12 +4,36 @@ export default { title: "Simple Meal Plan", subtitle: "Plane deine Mahlzeiten für die Woche", signinWith: "Anmelden mit {name}", - author: "Tim Ittermann", - privacy: "Datenschutz", + author: "von Tim Ittermann", + privacy: "Impressum / Datenschutz", myMealPlans: "Meine Essenspläne", logout: "Abmelden", profile: "Profile", }, + features: { + featureA: "Einfache Essensplanung", + featureADescription: "Plane super einfach den Essensplan", + featureAImgAlt: "Bild mit einem Screenshot der Anwendung", + + featureB: "Immer und überall", + featureBDescription1: + "Diese Webseite funktioniert auf dem Computer und dem Handy.", + featureBDescription2: "Somit hast du deinen Essensplan immer dabei!", + featureBImgAlt: "Bild von einem Handy und einem Computer", + + featureC: "Gemeinsam planen", + featureCDescription: + "Lade deine Mitbewohner oder Freunde ein und plane gemeinsam", + featureCImgAlt: "Bild von Menschen die gemeinsam essen", + + featureD: "Kostenlos & Werbefrei", + featureDDescription: + "Diese Anwendung ist kostenfrei und Werbefrei, weil niemand mag Werbung.", + + featureE: "Open-Source", + featureEDescription: + "Der Quellcode ist auf GitHub verfügbar. Jeder kann mithelfen diese Anwendung zu verbessern.", + }, mealPlan: { defaultLabel: "Mein Essensplan", }, diff --git a/src/locales/en.ts b/src/locales/en.ts index 9dd3f6f..d1cc6c1 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -4,12 +4,34 @@ export default { title: "Simple Meal Plan", subtitle: "Plan your meals for the week", signinWith: "Sign in with {name}", - author: "Tim Ittermann", - privacy: "Privacy", + author: "from Tim Ittermann", + privacy: "Imprint / Privacy", myMealPlans: "My Meal Plans", logout: "Logout", profile: "Profile", }, + features: { + featureA: "Simple Meal Planning", + featureADescription: "Plan your meal plan super easily", + featureAImgAlt: "Image with a screenshot of the application", + + featureB: "Always and everywhere", + featureBDescription1: "This website works on the computer and the phone.", + featureBDescription2: "So you always have your meal plan with you!", + featureBImgAlt: "Image of a phone and a computer", + + featureC: "Plan together", + featureCDescription: "Invite your roommates or friends and plan together", + featureCImgAlt: "Image of people eating together", + + featureD: "Free & Ad-Free", + featureDDescription: + "This application is free and ad-free, because no one likes ads.", + + featureE: "Open-Source", + featureEDescription: + "The source code is available on GitHub. Everyone can help to improve this application", + }, mealPlan: { defaultLabel: "My Meal Plan", },