├── .env.server.example
├── .gitignore
├── .wasproot
├── README.md
├── main.wasp
├── memerator.png
├── migrations
├── 20230901115956_init
│ └── migration.sql
├── 20230904084625_topics_audience
│ └── migration.sql
├── 20230904090820_ok
│ └── migration.sql
├── 20230904094512_is_admin
│ └── migration.sql
├── 20230904094950_credits
│ └── migration.sql
└── migration_lock.toml
├── postcss.config.cjs
├── src
├── .waspignore
├── client
│ ├── Layout.tsx
│ ├── Main.css
│ ├── pages
│ │ ├── EditMemePage.tsx
│ │ ├── Home.tsx
│ │ └── auth
│ │ │ ├── Login.tsx
│ │ │ └── Signup.tsx
│ ├── tsconfig.json
│ └── vite-env.d.ts
├── server
│ ├── actions.ts
│ ├── queries.ts
│ ├── tsconfig.json
│ ├── utils.ts
│ └── workers.ts
└── shared
│ └── tsconfig.json
└── tailwind.config.cjs
/.env.server.example:
--------------------------------------------------------------------------------
1 | # set up your own credentials on https://imgflip.com/signup and rename this file to .env.server
2 | # NOTE: make sure you register with Username and Password (not google)
3 | IMGFLIP_USERNAME=
4 | IMGFLIP_PASSWORD=
5 |
6 | # get your api key from https://platform.openai.com/
7 | OPENAI_API_KEY=
8 |
9 | JWT_SECRET=asecretphraseatleastthirtytwocharacterslong
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.wasp/
2 | /.env.server
3 | /.env.client
4 |
5 | # Local Netlify folder
6 | .netlify
7 |
--------------------------------------------------------------------------------
/.wasproot:
--------------------------------------------------------------------------------
1 | File marking the root of Wasp project.
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is a repo for the Memerator meme generator app: https://damemerator.netlify.app/
2 |
3 |
4 |
5 | It uses:
6 | - [Wasp](https://wasp-lang.dev/) a full-stack React/NodeJS framework
7 | - OpenAI's function calling API to call imgflip.com's API to generate memes
8 |
9 | Please see the tutorial on how to build this app: https://dev.to/wasp/build-your-own-ai-meme-generator-learn-how-to-use-openais-function-calls-1p21
--------------------------------------------------------------------------------
/main.wasp:
--------------------------------------------------------------------------------
1 | app Memerator {
2 | wasp: {
3 | version: "^0.11.3"
4 | },
5 | title: "Memerator",
6 | client: {
7 | rootComponent: import { Layout } from "@client/Layout",
8 | },
9 | db: {
10 | system: PostgreSQL,
11 | prisma: {
12 | clientPreviewFeatures: ["extendedWhereUnique"]
13 | }
14 | },
15 | auth: {
16 | userEntity: User,
17 | methods: {
18 | usernameAndPassword: {}
19 | },
20 | onAuthFailedRedirectTo: "/login",
21 | onAuthSucceededRedirectTo: "/"
22 | },
23 | dependencies: [
24 | ("openai", "4.2.0"),
25 | ("axios", "^1.4.0"),
26 | ("react-icons", "4.10.1"),
27 | ]
28 | }
29 |
30 | entity User {=psl
31 | id Int @id @default(autoincrement())
32 | username String @unique
33 | password String
34 | memes Meme[]
35 | isAdmin Boolean @default(false)
36 | credits Int @default(2)
37 | psl=}
38 |
39 | entity Meme {=psl
40 | id String @id @default(uuid())
41 | url String
42 | text0 String
43 | text1 String
44 | topics String
45 | audience String
46 | template Template @relation(fields: [templateId], references: [id])
47 | templateId String
48 | user User @relation(fields: [userId], references: [id])
49 | userId Int
50 | createdAt DateTime @default(now())
51 | psl=}
52 |
53 | entity Template {=psl
54 | id String @id @unique
55 | name String
56 | url String
57 | width Int
58 | height Int
59 | boxCount Int
60 | memes Meme[]
61 | psl=}
62 |
63 | action createMeme {
64 | fn: import { createMeme } from "@server/actions.js",
65 | entities: [Meme, Template, User]
66 | }
67 |
68 | action editMeme {
69 | fn: import { editMeme } from "@server/actions.js",
70 | entities: [Meme, Template, User]
71 | }
72 |
73 | action deleteMeme {
74 | fn: import { deleteMeme } from "@server/actions.js",
75 | entities: [Meme]
76 | }
77 |
78 | query getAllMemes {
79 | fn: import { getAllMemes } from "@server/queries.js",
80 | entities: [Meme]
81 | }
82 |
83 | query getMeme {
84 | fn: import { getMeme } from "@server/queries.js",
85 | entities: [Meme]
86 | }
87 |
88 | query getMemeTemplates {
89 | fn: import { getMemeTemplates } from "@server/queries.js",
90 | entities: [Template]
91 | }
92 |
93 | route HomePageRoute { path: "/", to: HomePage }
94 | page HomePage {
95 | component: import { HomePage } from "@client/pages/Home",
96 | }
97 |
98 | route EditMemeRoute { path: "/meme/:id", to: EditMemePage }
99 | page EditMemePage {
100 | component: import { EditMemePage } from "@client/pages/EditMemePage",
101 | authRequired: true
102 | }
103 |
104 | route LoginRoute { path: "/login", to: LoginPage }
105 | page LoginPage {
106 | component: import Login from "@client/pages/auth/Login"
107 | }
108 | route SignupRoute { path: "/signup", to: SignupPage }
109 | page SignupPage {
110 | component: import Signup from "@client/pages/auth/Signup"
111 | }
112 |
113 | job storeMemeTemplates {
114 | executor: PgBoss,
115 | perform: {
116 | fn: import { fetchAndStoreMemeTemplates } from "@server/workers.js",
117 | },
118 | schedule: {
119 | // daily at 7 a.m.
120 | cron: "0 7 * * *"
121 | },
122 | entities: [Template],
123 | }
124 |
--------------------------------------------------------------------------------
/memerator.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vincanger/memerator/7a66bf336f79474275ef35379c7c502b88b02ebf/memerator.png
--------------------------------------------------------------------------------
/migrations/20230901115956_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "User" (
3 | "id" SERIAL NOT NULL,
4 | "username" TEXT NOT NULL,
5 | "password" TEXT NOT NULL,
6 |
7 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
8 | );
9 |
10 | -- CreateTable
11 | CREATE TABLE "Meme" (
12 | "id" TEXT NOT NULL,
13 | "url" TEXT NOT NULL,
14 | "text0" TEXT NOT NULL,
15 | "text1" TEXT NOT NULL,
16 | "templateId" TEXT NOT NULL,
17 | "userId" INTEGER NOT NULL,
18 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
19 |
20 | CONSTRAINT "Meme_pkey" PRIMARY KEY ("id")
21 | );
22 |
23 | -- CreateTable
24 | CREATE TABLE "Template" (
25 | "id" TEXT NOT NULL,
26 | "name" TEXT NOT NULL,
27 | "url" TEXT NOT NULL,
28 | "width" INTEGER NOT NULL,
29 | "height" INTEGER NOT NULL,
30 | "boxCount" INTEGER NOT NULL,
31 |
32 | CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
33 | );
34 |
35 | -- CreateIndex
36 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
37 |
38 | -- CreateIndex
39 | CREATE UNIQUE INDEX "Template_id_key" ON "Template"("id");
40 |
41 | -- AddForeignKey
42 | ALTER TABLE "Meme" ADD CONSTRAINT "Meme_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
43 |
44 | -- AddForeignKey
45 | ALTER TABLE "Meme" ADD CONSTRAINT "Meme_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
46 |
--------------------------------------------------------------------------------
/migrations/20230904084625_topics_audience/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "Meme" ADD COLUMN "audience" TEXT NOT NULL DEFAULT 'web developers',
3 | ADD COLUMN "topics" TEXT NOT NULL DEFAULT 'css bugs';
4 |
--------------------------------------------------------------------------------
/migrations/20230904090820_ok/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "Meme" ALTER COLUMN "audience" DROP DEFAULT,
3 | ALTER COLUMN "topics" DROP DEFAULT;
4 |
--------------------------------------------------------------------------------
/migrations/20230904094512_is_admin/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "User" ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false;
3 |
--------------------------------------------------------------------------------
/migrations/20230904094950_credits/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "User" ADD COLUMN "credits" INTEGER NOT NULL DEFAULT 2;
3 |
--------------------------------------------------------------------------------
/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
--------------------------------------------------------------------------------
/src/.waspignore:
--------------------------------------------------------------------------------
1 | # Ignore editor tmp files
2 | **/*~
3 | **/#*#
4 |
--------------------------------------------------------------------------------
/src/client/Layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 | import { Link } from "react-router-dom";
3 | import useAuth from '@wasp/auth/useAuth';
4 | import logout from '@wasp/auth/logout';
5 | import { FaRegLaughBeam } from 'react-icons/fa';
6 | import "./Main.css";
7 |
8 | export const Layout = ({ children }: { children: ReactNode }) => {
9 | const { data: user } = useAuth();
10 |
11 | return (
12 |
13 |
36 |
{children}
37 |
47 |
48 | );
49 | };
--------------------------------------------------------------------------------
/src/client/Main.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --auth-form-brand: theme(colors.primary.500);
7 | --auth-form-brand-accent: theme(colors.primary.600);
8 | --auth-form-submit-button-text-color: theme(colors.white);
9 | }
--------------------------------------------------------------------------------
/src/client/pages/EditMemePage.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, FormEventHandler } from 'react';
2 | import { useQuery } from '@wasp/queries';
3 | import editMeme from '@wasp/actions/editMeme';
4 | import getMeme from '@wasp/queries/getMeme';
5 | import { useParams } from 'react-router-dom';
6 | import { AiOutlineEdit } from 'react-icons/ai';
7 |
8 | export function EditMemePage() {
9 | // http://localhost:3000/meme/573f283c-24e2-4c45-b6b9-543d0b7cc0c7
10 | const { id } = useParams<{ id: string }>();
11 |
12 | const [text0, setText0] = useState('');
13 | const [text1, setText1] = useState('');
14 | const [isLoading, setIsLoading] = useState(false);
15 |
16 | const { data: meme, isLoading: isMemeLoading, error: memeError } = useQuery(getMeme, { id: id });
17 |
18 | useEffect(() => {
19 | if (meme) {
20 | setText0(meme.text0);
21 | setText1(meme.text1);
22 | }
23 | }, [meme]);
24 |
25 | const handleSubmit: FormEventHandler = async (e) => {
26 | e.preventDefault();
27 | try {
28 | setIsLoading(true);
29 | await editMeme({ id, text0, text1 });
30 | } catch (error: any) {
31 | alert('Error generating meme: ' + error.message);
32 | } finally {
33 | setIsLoading(false);
34 | }
35 | };
36 |
37 | if (isMemeLoading) return 'Loading...';
38 | if (memeError) return 'Error: ' + memeError.message;
39 |
40 | return (
41 |
42 |
Edit Meme
43 |
82 | {!!meme && (
83 |
84 |

85 |
86 |
87 | Topics:
88 | {meme.topics}
89 |
90 |
91 | Audience:
92 | {meme.audience}
93 |
94 |
95 | ImgFlip Template:
96 | {meme.template.name}
97 |
98 |
99 |
100 | )}
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/src/client/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import { useState, FormEventHandler } from 'react';
2 | import { Link } from '@wasp/router';
3 | import { useQuery } from '@wasp/queries';
4 | import createMeme from '@wasp/actions/createMeme';
5 | import getAllMemes from '@wasp/queries/getAllMemes';
6 | import deleteMeme from '@wasp/actions/deleteMeme';
7 | import useAuth from '@wasp/auth/useAuth';
8 | import { useHistory } from 'react-router-dom';
9 | import {
10 | AiOutlineEdit,
11 | AiOutlineDelete,
12 | AiOutlinePlusCircle,
13 | AiOutlineMinusCircle,
14 | AiOutlineRobot,
15 | } from 'react-icons/ai';
16 |
17 | export function HomePage() {
18 | const [topics, setTopics] = useState(['']);
19 | const [audience, setAudience] = useState('');
20 | const [isMemeGenerating, setIsMemeGenerating] = useState(false);
21 |
22 | const history = useHistory();
23 | const { data: user } = useAuth();
24 | const { data: memes, isLoading, error } = useQuery(getAllMemes);
25 |
26 | const handleGenerateMeme: FormEventHandler = async (e) => {
27 | e.preventDefault();
28 | if (!user) {
29 | history.push('/login');
30 | return;
31 | }
32 | if (topics.join('').trim().length === 0 || audience.length === 0) {
33 | alert('Please provide topic and audience');
34 | return;
35 | }
36 | try {
37 | setIsMemeGenerating(true);
38 | await createMeme({ topics, audience });
39 | } catch (error: any) {
40 | alert('Error generating meme: ' + error.message);
41 | } finally {
42 | setIsMemeGenerating(false);
43 | }
44 | };
45 |
46 | const handleDeleteMeme = async (id: string) => {
47 | const shouldDelete = window.confirm('Are you sure you want to delete this meme?');
48 | if (!shouldDelete) return;
49 | try {
50 | await deleteMeme({ id: id });
51 | } catch (error: any) {
52 | alert('Error deleting meme: ' + error.message);
53 | }
54 | };
55 |
56 | if (isLoading) return 'Loading...';
57 | if (error) return 'Error: ' + error;
58 |
59 | return (
60 |
61 |
Welcome to Memerator!
62 |
Start generating meme ideas by providing topics and intended audience.
63 |
122 |
123 | {!!memes && memes.length > 0 ? (
124 | memes.map((memeIdea) => (
125 |
126 |

127 |
128 |
129 | Topics:
130 | {memeIdea.topics}
131 |
132 |
133 | Audience:
134 | {memeIdea.audience}
135 |
136 |
137 | {user && (user.isAdmin || user.id === memeIdea.userId) && (
138 |
139 |
140 |
144 |
145 |
152 |
153 | )}
154 |
155 | ))
156 | ) : (
157 |
:( no memes found
158 | )}
159 |
160 | );
161 | }
162 |
--------------------------------------------------------------------------------
/src/client/pages/auth/Login.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@wasp/router";
2 | import { LoginForm } from "@wasp/auth/forms/Login";
3 |
4 | export default function Login() {
5 | return (
6 |
7 |
8 |
9 |
10 |
19 |
20 | If you don't have an account go to{" "}
21 |
22 | sign up
23 |
24 | .
25 |
26 |
27 |
28 |
29 |
30 | );
31 | }
--------------------------------------------------------------------------------
/src/client/pages/auth/Signup.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@wasp/router";
2 | import { SignupForm } from "@wasp/auth/forms/Signup";
3 |
4 | export default function Signup() {
5 | return (
6 |
7 |
8 |
9 |
10 |
19 |
20 | If you already have an account go to{" "}
21 |
22 | login
23 |
24 | .
25 |
26 |
27 |
28 |
29 |
30 | );
31 | }
--------------------------------------------------------------------------------
/src/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | // =============================== IMPORTANT =================================
2 | //
3 | // This file is only used for Wasp IDE support. You can change it to configure
4 | // your IDE checks, but none of these options will affect the TypeScript
5 | // compiler. Proper TS compiler configuration in Wasp is coming soon :)
6 | {
7 | "compilerOptions": {
8 | // JSX support
9 | "jsx": "preserve",
10 | "strict": true,
11 | // Allow default imports.
12 | "esModuleInterop": true,
13 | "lib": [
14 | "dom",
15 | "dom.iterable",
16 | "esnext"
17 | ],
18 | "allowJs": true,
19 | // Wasp needs the following settings enable IDE support in your source
20 | // files. Editing them might break features like import autocompletion and
21 | // definition lookup. Don't change them unless you know what you're doing.
22 | //
23 | // The relative path to the generated web app's root directory. This must be
24 | // set to define the "paths" option.
25 | "baseUrl": "../../.wasp/out/web-app/",
26 | "paths": {
27 | // Resolve all "@wasp" imports to the generated source code.
28 | "@wasp/*": [
29 | "src/*"
30 | ],
31 | // Resolve all non-relative imports to the correct node module. Source:
32 | // https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
33 | "*": [
34 | // Start by looking for the definiton inside the node modules root
35 | // directory...
36 | "node_modules/*",
37 | // ... If that fails, try to find it inside definitely-typed type
38 | // definitions.
39 | "node_modules/@types/*"
40 | ]
41 | },
42 | // Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots
43 | "typeRoots": [
44 | "../../.wasp/out/web-app/node_modules/@types"
45 | ],
46 | // Since this TS config is used only for IDE support and not for
47 | // compilation, the following directory doesn't exist. We need to specify
48 | // it to prevent this error:
49 | // https://stackoverflow.com/questions/42609768/typescript-error-cannot-write-file-because-it-would-overwrite-input-file
50 | "outDir": "phantom"
51 | },
52 | "exclude": [
53 | "phantom"
54 | ],
55 | }
--------------------------------------------------------------------------------
/src/client/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/server/actions.ts:
--------------------------------------------------------------------------------
1 | import HttpError from '@wasp/core/HttpError.js';
2 | import OpenAI from 'openai';
3 | import { fetchMemeTemplates, generateMemeImage, decrementUserCredits } from './utils.js';
4 |
5 | import type { CreateMeme, EditMeme, DeleteMeme } from '@wasp/actions/types';
6 | import type { Meme, Template } from '@wasp/entities';
7 |
8 | type CreateMemeArgs = { topics: string[]; audience: string };
9 | type EditMemeArgs = Pick;
10 | type DeleteMemeArgs = Pick;
11 |
12 | const openai = new OpenAI({
13 | apiKey: process.env.OPENAI_API_KEY,
14 | });
15 |
16 | export const createMeme: CreateMeme = async ({ topics, audience }, context) => {
17 | if (!context.user) {
18 | throw new HttpError(401, 'You must be logged in');
19 | }
20 |
21 | if (context.user.credits === 0 && !context.user.isAdmin) {
22 | throw new HttpError(403, 'You have no credits left');
23 | }
24 |
25 | const topicsStr = topics.join(', ');
26 |
27 | let templates: Template[] = await context.entities.Template.findMany({});
28 |
29 | if (templates.length === 0) {
30 | const memeTemplates = await fetchMemeTemplates();
31 | templates = await Promise.all(
32 | memeTemplates.map(async (template: any) => {
33 | const addedTemplate = await context.entities.Template.upsert({
34 | where: { id: template.id },
35 | create: {
36 | id: template.id,
37 | name: template.name,
38 | url: template.url,
39 | width: template.width,
40 | height: template.height,
41 | boxCount: template.box_count,
42 | },
43 | update: {},
44 | });
45 |
46 | return addedTemplate;
47 | })
48 | );
49 | }
50 |
51 | // filter out templates with box_count > 2
52 | templates = templates.filter((template) => template.boxCount <= 2);
53 | const randomTemplate = templates[Math.floor(Math.random() * templates.length)];
54 |
55 | console.log('random template: ', randomTemplate);
56 |
57 | const sysPrompt = `You are a meme idea generator. You will use the imgflip api to generate a meme based on an idea you suggest. Given a random template name and topics, generate a meme idea for the intended audience. Only use the template provided`;
58 | const userPrompt = `Topics: ${topicsStr} \n Intended Audience: ${audience} \n Template: ${randomTemplate.name} \n`;
59 |
60 | let openAIResponse: OpenAI.Chat.Completions.ChatCompletion;
61 | try {
62 | openAIResponse = await openai.chat.completions.create({
63 | messages: [
64 | { role: 'system', content: sysPrompt },
65 | { role: 'user', content: userPrompt },
66 | ],
67 | functions: [
68 | {
69 | name: 'generateMemeImage',
70 | description: 'Generate meme via the imgflip API based on the given idea',
71 | parameters: {
72 | type: 'object',
73 | properties: {
74 | text0: { type: 'string', description: 'The text for the top caption of the meme' },
75 | text1: { type: 'string', description: 'The text for the bottom caption of the meme' },
76 | },
77 | required: ['templateName', 'text0', 'text1'],
78 | },
79 | },
80 | ],
81 | function_call: {
82 | name: 'generateMemeImage',
83 | },
84 | model: 'gpt-4-0613',
85 | });
86 | } catch (error: any) {
87 | console.error('Error calling openAI: ', error);
88 | throw new HttpError(500, 'Error calling openAI');
89 | }
90 |
91 | console.log(openAIResponse.choices[0]);
92 |
93 | /**
94 | * the Function call returned by openAI looks like this:
95 | */
96 | // {
97 | // index: 0,
98 | // message: {
99 | // role: 'assistant',
100 | // content: null,
101 | // function_call: {
102 | // name: 'generateMeme',
103 | // arguments: '{\n' +
104 | // ` "text0": "CSS you've been writing all day",\n` +
105 | // ' "text1": "This looks horrible"\n' +
106 | // '}'
107 | // }
108 | // },
109 | // finish_reason: 'stop'
110 | // }
111 | if (!openAIResponse.choices[0].message.function_call) throw new HttpError(500, 'No function call in openAI response');
112 |
113 | const gptArgs = JSON.parse(openAIResponse.choices[0].message.function_call.arguments);
114 | console.log('gptArgs: ', gptArgs);
115 |
116 | const memeIdeaText0 = gptArgs.text0;
117 | const memeIdeaText1 = gptArgs.text1;
118 |
119 | console.log('meme Idea args: ', memeIdeaText0, memeIdeaText1);
120 |
121 | const memeUrl = await generateMemeImage({
122 | templateId: randomTemplate.id,
123 | text0: memeIdeaText0,
124 | text1: memeIdeaText1,
125 | });
126 |
127 | const newMeme = await context.entities.Meme.create({
128 | data: {
129 | text0: memeIdeaText0,
130 | text1: memeIdeaText1,
131 | topics: topicsStr,
132 | audience: audience,
133 | url: memeUrl,
134 | template: { connect: { id: randomTemplate.id } },
135 | user: { connect: { id: context.user.id } },
136 | },
137 | });
138 |
139 | if (newMeme && !context.user.isAdmin) await decrementUserCredits(context.user.id, context);
140 |
141 | return newMeme;
142 | };
143 |
144 | export const editMeme: EditMeme = async ({ id, text0, text1 }, context) => {
145 | if (!context.user) {
146 | throw new HttpError(401, 'You must be logged in');
147 | }
148 |
149 | const meme = await context.entities.Meme.findUniqueOrThrow({
150 | where: { id: id },
151 | include: { template: true },
152 | });
153 |
154 | if (!context.user.isAdmin && meme.userId !== context.user.id) {
155 | throw new HttpError(403, 'You are not the creator of this meme');
156 | }
157 |
158 | const memeUrl = await generateMemeImage({
159 | templateId: meme.template.id,
160 | text0: text0,
161 | text1: text1,
162 | });
163 |
164 | const newMeme = await context.entities.Meme.update({
165 | where: { id: id },
166 | data: {
167 | text0: text0,
168 | text1: text1,
169 | url: memeUrl,
170 | },
171 | });
172 |
173 | return newMeme;
174 | };
175 |
176 | export const deleteMeme: DeleteMeme = async ({ id }, context) => {
177 | if (!context.user) {
178 | throw new HttpError(401, 'You must be logged in');
179 | }
180 |
181 | const meme = await context.entities.Meme.findUniqueOrThrow({
182 | where: { id: id },
183 | });
184 |
185 | if (!context.user.isAdmin && meme.userId !== context.user.id) {
186 | throw new HttpError(403, 'You are not the creator of this meme');
187 | }
188 |
189 | return await context.entities.Meme.delete({ where: { id: id } });
190 | };
191 |
--------------------------------------------------------------------------------
/src/server/queries.ts:
--------------------------------------------------------------------------------
1 | import HttpError from '@wasp/core/HttpError.js';
2 |
3 | import type { Meme, Template } from '@wasp/entities';
4 | import type { GetAllMemes, GetMeme, GetMemeTemplates } from '@wasp/queries/types';
5 |
6 | type GetMemeArgs = { id: string };
7 |
8 | export const getAllMemes: GetAllMemes = async (_args, context) => {
9 | const memeIdeas = await context.entities.Meme.findMany({
10 | orderBy: { createdAt: 'desc' },
11 | include: { template: true },
12 | });
13 |
14 | return memeIdeas;
15 | };
16 |
17 | export const getMeme: GetMeme = async ({ id }, context) => {
18 | if (!context.user) {
19 | throw new HttpError(401);
20 | }
21 |
22 | const meme = await context.entities.Meme.findUniqueOrThrow({
23 | where: { id: id },
24 | include: { template: true },
25 | });
26 |
27 | return meme;
28 | };
29 |
30 | export const getMemeTemplates: GetMemeTemplates = async (_arg, context) => {
31 | if (!context.user) {
32 | throw new HttpError(401);
33 | }
34 |
35 | const memeTemplates = await context.entities.Template.findMany({});
36 |
37 | if (!memeTemplates) {
38 | throw new HttpError(404, 'No meme templates found');
39 | }
40 |
41 | return memeTemplates;
42 | };
43 |
--------------------------------------------------------------------------------
/src/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | // =============================== IMPORTANT =================================
2 | //
3 | // This file is only used for Wasp IDE support. You can change it to configure
4 | // your IDE checks, but none of these options will affect the TypeScript
5 | // compiler. Proper TS compiler configuration in Wasp is coming soon :)
6 | {
7 | "compilerOptions": {
8 | // Allows default imports.
9 | "esModuleInterop": true,
10 | "allowJs": true,
11 | "strict": true,
12 | // Wasp needs the following settings enable IDE support in your source
13 | // files. Editing them might break features like import autocompletion and
14 | // definition lookup. Don't change them unless you know what you're doing.
15 | //
16 | // The relative path to the generated web app's root directory. This must be
17 | // set to define the "paths" option.
18 | "baseUrl": "../../.wasp/out/server/",
19 | "paths": {
20 | // Resolve all "@wasp" imports to the generated source code.
21 | "@wasp/*": [
22 | "src/*"
23 | ],
24 | // Resolve all non-relative imports to the correct node module. Source:
25 | // https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
26 | "*": [
27 | // Start by looking for the definiton inside the node modules root
28 | // directory...
29 | "node_modules/*",
30 | // ... If that fails, try to find it inside definitely-typed type
31 | // definitions.
32 | "node_modules/@types/*"
33 | ]
34 | },
35 | // Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots
36 | "typeRoots": [
37 | "../../.wasp/out/server/node_modules/@types"
38 | ],
39 | // Since this TS config is used only for IDE support and not for
40 | // compilation, the following directory doesn't exist. We need to specify
41 | // it to prevent this error:
42 | // https://stackoverflow.com/questions/42609768/typescript-error-cannot-write-file-because-it-would-overwrite-input-file
43 | "outDir": "phantom",
44 | },
45 | "exclude": [
46 | "phantom"
47 | ],
48 | }
--------------------------------------------------------------------------------
/src/server/utils.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { stringify } from 'querystring';
3 | import HttpError from '@wasp/core/HttpError.js';
4 |
5 | type GenerateMemeArgs = {
6 | text0: string;
7 | text1: string;
8 | templateId: string;
9 | };
10 |
11 | export const fetchMemeTemplates = async () => {
12 | try {
13 | const response = await axios.get('https://api.imgflip.com/get_memes');
14 | return response.data.data.memes;
15 | } catch (error) {
16 | console.error(error);
17 | throw new HttpError(500, 'Error fetching meme templates');
18 | }
19 | };
20 |
21 | export const generateMemeImage = async (args: GenerateMemeArgs) => {
22 | console.log('args: ', args);
23 |
24 | try {
25 | const data = stringify({
26 | template_id: args.templateId,
27 | username: process.env.IMGFLIP_USERNAME,
28 | password: process.env.IMGFLIP_PASSWORD,
29 | text0: args.text0,
30 | text1: args.text1,
31 | });
32 |
33 | // Implement the generation of meme using the Imgflip API
34 | const res = await axios.post('https://api.imgflip.com/caption_image', data, {
35 | headers: {
36 | 'Content-Type': 'application/x-www-form-urlencoded',
37 | },
38 | });
39 |
40 | const url = res.data.data.url;
41 |
42 | console.log('generated meme url: ', url);
43 |
44 | return url as string;
45 | } catch (error) {
46 | console.error(error);
47 | throw new HttpError(500, 'Error generating meme image');
48 | }
49 | };
50 |
51 | export const decrementUserCredits = async (userId: number, context: any) => {
52 | const user = await context.entities.User.findUnique({ where: { id: userId } });
53 | if (!user) throw new HttpError(404, 'No user with id ' + userId);
54 |
55 | if (user.credits === 0) throw new HttpError(403, 'You have no credits left');
56 |
57 | return await context.entities.User.update({
58 | where: { id: userId },
59 | data: { credits: user.credits - 1 },
60 | });
61 | };
--------------------------------------------------------------------------------
/src/server/workers.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | export const fetchAndStoreMemeTemplates = async (_args: any, context: any) => {
4 | console.log('.... ><><>< get meme templates cron starting ><><>< ....');
5 |
6 | try {
7 | const response = await axios.get('https://api.imgflip.com/get_memes');
8 |
9 | const promises = response.data.data.memes.map((meme: any) => {
10 | return context.entities.Template.upsert({
11 | where: { id: meme.id },
12 | create: {
13 | id: meme.id,
14 | name: meme.name,
15 | url: meme.url,
16 | width: meme.width,
17 | height: meme.height,
18 | boxCount: meme.box_count,
19 | },
20 | update: {},
21 | });
22 | });
23 |
24 | await Promise.all(promises);
25 | } catch (error) {
26 | console.error('error fetching meme templates: ', error);
27 | }
28 | };
29 |
--------------------------------------------------------------------------------
/src/shared/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // Enable default imports in TypeScript.
4 | "esModuleInterop": true,
5 | "allowJs": true,
6 | // The following settings enable IDE support in user-provided source files.
7 | // Editing them might break features like import autocompletion and
8 | // definition lookup. Don't change them unless you know what you're doing.
9 | //
10 | // The relative path to the generated web app's root directory. This must be
11 | // set to define the "paths" option.
12 | "baseUrl": "../../.wasp/out/server/",
13 | "paths": {
14 | // Resolve all non-relative imports to the correct node module. Source:
15 | // https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
16 | "*": [
17 | // Start by looking for the definiton inside the node modules root
18 | // directory...
19 | "node_modules/*",
20 | // ... If that fails, try to find it inside definitely-typed type
21 | // definitions.
22 | "node_modules/@types/*"
23 | ]
24 | },
25 | // Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots
26 | "typeRoots": ["../../.wasp/out/server/node_modules/@types"]
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | const colors = require('tailwindcss/colors')
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | content: [
6 | "./src/**/*.{js,jsx,ts,tsx}",
7 | ],
8 | theme: {
9 | extend: {
10 | colors: {
11 | primary: {
12 | 50: colors.yellow[50],
13 | 100: colors.yellow[100],
14 | 200: colors.yellow[200],
15 | 300: colors.yellow[300],
16 | 400: colors.yellow[400],
17 | 500: colors.yellow[500],
18 | 600: colors.yellow[600],
19 | 700: colors.yellow[700],
20 | 800: colors.yellow[800],
21 | 900: colors.yellow[900],
22 | }
23 | }
24 | },
25 | },
26 | }
--------------------------------------------------------------------------------