diff --git a/api/duolingo.js b/api/duolingo.js new file mode 100644 index 0000000..021dbfc --- /dev/null +++ b/api/duolingo.js @@ -0,0 +1,106 @@ +import { Client } from '@neondatabase/serverless'; + +export const config = { + runtime: 'edge', +}; + +export default async function (req, event) { + const url = new URL(req.url); + + // Handle CORS + if (req.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); + } + + if (!process.env.NETLIFY_DATABASE_URL) { + console.error("NETLIFY_DATABASE_URL not set"); + return new Response(JSON.stringify({ error: "Server configuration error" }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + const client = new Client(process.env.NETLIFY_DATABASE_URL); + + try { + await client.connect(); + + if (req.method === 'GET') { + // Create table if not exists (lazy initialization for simplicity in this demo) + // Note: In production, use migrations. + await client.query(` + CREATE TABLE IF NOT EXISTS duolingo_stats ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL DEFAULT CURRENT_DATE, + streak_count INTEGER NOT NULL, + accuracy_percentage INTEGER NOT NULL, + words_learned TEXT, + leaderboard_position INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + `); + + const { rows } = await client.query('SELECT * FROM duolingo_stats ORDER BY date DESC, created_at DESC'); + event.waitUntil(client.end()); + + return new Response(JSON.stringify(rows), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + + if (req.method === 'POST') { + const body = await req.json(); + const { date, streak_count, accuracy_percentage, words_learned, leaderboard_position } = body; + + if (streak_count === undefined || accuracy_percentage === undefined) { + return new Response(JSON.stringify({ error: "Missing required fields" }), { + status: 400, + headers: { 'Access-Control-Allow-Origin': '*' } + }); + } + + const dateVal = date || new Date().toISOString().split('T')[0]; + + const query = ` + INSERT INTO duolingo_stats (date, streak_count, accuracy_percentage, words_learned, leaderboard_position) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `; + const values = [dateVal, streak_count, accuracy_percentage, words_learned, leaderboard_position]; + + const { rows } = await client.query(query, values); + + event.waitUntil(client.end()); + + return new Response(JSON.stringify(rows[0]), { + status: 201, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + + return new Response("Method not allowed", { status: 405 }); + + } catch (error) { + console.error("Database error:", error); + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } +} diff --git a/package-lock.json b/package-lock.json index 5b4c1da..9ff7af7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@ampproject/toolbox-optimizer": "^2.9.0", + "@neondatabase/serverless": "^1.0.2", "any-shell-escape": "^0.1.1", "clean-css": "^4.2.3", "concurrently": "^7.0.0", @@ -66,6 +67,7 @@ "integrity": "sha512-03ER4zukR6BgwppI5DHRE11lc+8B0fWsBrqacVWo3o49QkdEFXnEWjhyI9qd9LrPlgQHK2/MYyxuOvNwecyCLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@11ty/dependency-tree": "^2.0.1", "@11ty/eleventy-utils": "^1.0.1", @@ -518,6 +520,28 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@neondatabase/serverless": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.2.tgz", + "integrity": "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.15.30", + "@types/pg": "^8.8.0" + }, + "engines": { + "node": ">=19.0.0" + } + }, + "node_modules/@neondatabase/serverless/node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -665,6 +689,17 @@ "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/a-sync-waterfall": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", @@ -5380,6 +5415,37 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "license": "MIT" }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/phin": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/phin/-/phin-3.7.1.tgz", @@ -5468,6 +5534,7 @@ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.2.13.tgz", "integrity": "sha512-FCE5xLH+hjbzRdpbRb1IMCvPv9yZx2QnDarBEYSN0N0HYk+TcXsEhwdFcFb+SRWOKzKGErhIEbBK2ogyLdTtfQ==", "license": "MIT", + "peer": true, "dependencies": { "colorette": "^1.2.2", "nanoid": "^3.1.22", @@ -5510,6 +5577,45 @@ "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/posthtml": { "version": "0.16.7", "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz", @@ -6554,6 +6660,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -7758,6 +7865,12 @@ "dev": true, "license": "ISC" }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -8073,6 +8186,15 @@ "node": ">=0.4.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index a92a741..9a5c4c7 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "dependencies": { "@ampproject/toolbox-optimizer": "^2.9.0", + "@neondatabase/serverless": "^1.0.2", "any-shell-escape": "^0.1.1", "clean-css": "^4.2.3", "concurrently": "^7.0.0", diff --git a/src/pages/duolingo.md b/src/pages/duolingo.md index 00d9a7d..f46ff45 100644 --- a/src/pages/duolingo.md +++ b/src/pages/duolingo.md @@ -1,8 +1,180 @@ --- title: "Duolingo" date: 2024-09-22 +layout: layouts/base.njk +templateEngineOverride: njk,md --- [Join me on Duolingo](https://www.duolingo.com/profile/SiJobling?via=share_profile_qr) where I’m continuing to regularly learn French. Follow me with the QR code below. [![](images/Duolingo_Sharing-782x1024.png)](https://www.duolingo.com/profile/SiJobling?via=share_profile_qr) + +

Progress Dashboard

+ +
+ +
+ +

History

+ + + + + + + + + + + + + +
DateStreakAccuracyWordsPosition
Loading data...
+ +
+Add New Record +
+
+
+
+
+
+ +
+
+ + + + +