├── .eslintrc.json ├── public ├── hero.jpg ├── french.png ├── german.png ├── chinese.png ├── english.png ├── favicon.ico ├── herophone.png ├── loginill.jpg ├── loginill.png ├── opengraph.jpg ├── russian.png ├── turkish.png ├── en_paradox.png ├── fr_paradox.png ├── loginimage.png ├── ru_paradox.png ├── tr_paradox.png ├── zh_paradox.png ├── vercel.svg ├── favicon.svg ├── mylogofav.svg ├── myfav.svg ├── next.svg └── reallogo.svg ├── src ├── app │ ├── favicon.ico │ ├── page.tsx │ ├── onboard │ │ └── page.tsx │ ├── app │ │ ├── edit │ │ │ └── [id] │ │ │ │ ├── error.tsx │ │ │ │ ├── _actions.ts │ │ │ │ ├── _delete.tsx │ │ │ │ └── page.tsx │ │ ├── word │ │ │ └── [id] │ │ │ │ ├── error.tsx │ │ │ │ └── page.tsx │ │ ├── profile │ │ │ ├── _actions.ts │ │ │ └── page.tsx │ │ ├── play │ │ │ └── page.tsx │ │ ├── upgrade │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── layout.tsx │ │ ├── ask │ │ │ └── [lang] │ │ │ │ └── page.tsx │ │ ├── repeat │ │ │ └── page.tsx │ │ ├── nopoints │ │ │ └── page.tsx │ │ └── _header.tsx │ ├── api │ │ ├── googleoauth │ │ │ ├── _oauth-client.ts │ │ │ ├── login │ │ │ │ └── route.ts │ │ │ └── callback │ │ │ │ └── route.ts │ │ ├── events │ │ │ ├── error │ │ │ │ └── route.ts │ │ │ └── visit │ │ │ │ └── route.ts │ │ ├── update │ │ │ ├── point │ │ │ │ └── route.ts │ │ │ └── role │ │ │ │ └── route.ts │ │ ├── guest │ │ │ └── route.ts │ │ ├── ask │ │ │ └── route.ts │ │ └── webhook │ │ │ └── stripe │ │ │ └── route.ts │ ├── layout.tsx │ ├── globals.css │ ├── login │ │ └── page.tsx │ └── admin │ │ └── page.tsx ├── utils │ ├── server │ │ ├── sleep.ts │ │ ├── auth-state-schema.ts │ │ ├── capitaliza.ts │ │ ├── calculate-points.ts │ │ ├── schemas.ts │ │ ├── events.ts │ │ ├── date.ts │ │ ├── generateToken.ts │ │ ├── guesthash.ts │ │ ├── system-langs.ts │ │ ├── check-token.ts │ │ ├── format-data.ts │ │ └── prompt.ts │ └── client │ │ ├── scroll-to-elem.ts │ │ └── fingerprint.ts ├── feature │ ├── local │ │ ├── action.ts │ │ └── index.tsx │ ├── webscrap │ │ ├── worddesc-action.ts │ │ ├── index.tsx │ │ └── worddesc.ts │ ├── pricing │ │ ├── titles.tsx │ │ ├── button.tsx │ │ └── index.tsx │ ├── home │ │ ├── actions.ts │ │ ├── index.tsx │ │ ├── card.tsx │ │ └── change-lang.tsx │ ├── onboard │ │ ├── actions.ts │ │ └── index.tsx │ ├── analytics │ │ └── index.tsx │ ├── landing │ │ ├── stats.tsx │ │ ├── faq.tsx │ │ ├── howworks.tsx │ │ ├── header.tsx │ │ └── index.tsx │ ├── repeat │ │ ├── index.tsx │ │ └── starter.tsx │ ├── render-word │ │ └── index.tsx │ └── ask │ │ └── index.tsx ├── lib │ └── utils.ts ├── db │ ├── index.ts │ └── schema.ts ├── i18n.ts └── components │ ├── common │ └── button.tsx │ └── ui │ ├── button.tsx │ ├── dialog.tsx │ ├── select.tsx │ ├── carousel.tsx │ └── dropdown-menu.tsx ├── postcss.config.mjs ├── drizzle.config.ts ├── drizzle ├── 0001_wooden_the_hand.sql ├── 0002_lethal_bastion.sql ├── meta │ └── _journal.json └── 0000_conscious_zuras.sql ├── docker-compose.yml ├── components.json ├── .gitignore ├── .dockerignore ├── next.config.js ├── tsconfig.json ├── README.md ├── package.json ├── Dockerfile ├── tailwind.config.ts └── messages └── zh.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/hero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/hero.jpg -------------------------------------------------------------------------------- /public/french.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/french.png -------------------------------------------------------------------------------- /public/german.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/german.png -------------------------------------------------------------------------------- /public/chinese.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/chinese.png -------------------------------------------------------------------------------- /public/english.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/english.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/favicon.ico -------------------------------------------------------------------------------- /public/herophone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/herophone.png -------------------------------------------------------------------------------- /public/loginill.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/loginill.jpg -------------------------------------------------------------------------------- /public/loginill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/loginill.png -------------------------------------------------------------------------------- /public/opengraph.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/opengraph.jpg -------------------------------------------------------------------------------- /public/russian.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/russian.png -------------------------------------------------------------------------------- /public/turkish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/turkish.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/src/app/favicon.ico -------------------------------------------------------------------------------- /public/en_paradox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/en_paradox.png -------------------------------------------------------------------------------- /public/fr_paradox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/fr_paradox.png -------------------------------------------------------------------------------- /public/loginimage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/loginimage.png -------------------------------------------------------------------------------- /public/ru_paradox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/ru_paradox.png -------------------------------------------------------------------------------- /public/tr_paradox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/tr_paradox.png -------------------------------------------------------------------------------- /public/zh_paradox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/swfront/main/public/zh_paradox.png -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { LandingPage } from "@/feature/landing"; 2 | 3 | export default async function Home() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/onboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { Onboarding } from "@/feature/onboard"; 2 | 3 | export default function Onboard() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/server/sleep.ts: -------------------------------------------------------------------------------- 1 | export async function sleep(ms: number): Promise { 2 | await new Promise((resolve) => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/utils/server/auth-state-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const AuthStateSchema = z.object({ 4 | from_language: z.string(), 5 | to_language: z.string(), 6 | }); 7 | -------------------------------------------------------------------------------- /src/feature/local/action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies } from "next/headers"; 4 | 5 | export async function LocalChangeAction(value: string) { 6 | cookies().set("APP_LOCALE", value); 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/server/capitaliza.ts: -------------------------------------------------------------------------------- 1 | export function Capitalize(str: string) { 2 | if (typeof str !== "string" || str.length === 0) { 3 | return ""; 4 | } 5 | return str.charAt(0).toUpperCase() + str.slice(1); 6 | } 7 | -------------------------------------------------------------------------------- /src/app/app/edit/[id]/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | 5 | export default function Errorpage() { 6 | const router = useRouter(); 7 | router.push("/app"); 8 | return
error
; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/app/word/[id]/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | 5 | export default function Errorpage() { 6 | const router = useRouter(); 7 | router.push("/app"); 8 | return
error
; 9 | } 10 | -------------------------------------------------------------------------------- /src/feature/webscrap/worddesc-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getWordDesc } from "./worddesc"; 4 | 5 | export async function WordDescAction(word: string, lang: string) { 6 | const obj = await getWordDesc(word, lang); 7 | return obj; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/app/profile/_actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies } from "next/headers"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export async function Logout() { 7 | cookies().delete("Authorization"); 8 | redirect("/login"); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/server/calculate-points.ts: -------------------------------------------------------------------------------- 1 | export function CalculatePoints(plan: string | null | undefined) { 2 | switch (plan) { 3 | case "free": 4 | return 50; 5 | case "premium": 6 | return 700; 7 | default: 8 | return 50; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/server/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const ErrorSchema = z.object({ 4 | from: z.string(), 5 | cause: z.string(), 6 | }); 7 | 8 | export const VisitSchema = z.object({ 9 | path: z.string().url(), 10 | user: z.string(), 11 | }); 12 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | export default defineConfig({ 3 | schema: "./src/db/schema.ts", 4 | out: "./drizzle", 5 | dialect: "sqlite", 6 | dbCredentials: { 7 | url: process.env.SQLITE_PATH || "./data.db", 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/app/api/googleoauth/_oauth-client.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2Client } from "google-auth-library"; 2 | 3 | export const GoogleClient = new OAuth2Client({ 4 | clientId: process.env.GOOGLE_ID, 5 | clientSecret: process.env.GOOGLE_SECRET, 6 | redirectUri: process.env.REDIRECT_URL, 7 | }); 8 | -------------------------------------------------------------------------------- /src/app/app/play/page.tsx: -------------------------------------------------------------------------------- 1 | import { RepeatStart } from "@/feature/repeat/starter"; 2 | import { Auth } from "@/utils/server/check-token"; 3 | 4 | export default async function PlayPage() { 5 | const result = await Auth(); 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /drizzle/0001_wooden_the_hand.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `guest_word` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `title` text NOT NULL, 4 | `description` text, 5 | `from_language` text, 6 | `to_language` text, 7 | `guest_id` text, 8 | `created_at` text DEFAULT CURRENT_TIMESTAMP 9 | ); 10 | -------------------------------------------------------------------------------- /drizzle/0002_lethal_bastion.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `word_descs` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `word_title` text, 4 | `to_language` text, 5 | `pos` text, 6 | `level` text, 7 | `pronounce` text, 8 | `article` text, 9 | `created_at` text DEFAULT CURRENT_TIMESTAMP 10 | ); 11 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/better-sqlite3"; 2 | import Database from "better-sqlite3"; 3 | import { migrate } from "drizzle-orm/better-sqlite3/migrator"; 4 | 5 | export const sqlite = new Database(process.env.SQLITE_PATH); 6 | export const db = drizzle(sqlite); 7 | migrate(db, { migrationsFolder: "./drizzle" }); 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | sw_app: 5 | image: swtest:0.0.3 6 | container_name: sw_app 7 | ports: 8 | - "3001:3000" 9 | volumes: 10 | - ./.env:/app/.env 11 | - ./maindata.db:/app/maindata.db 12 | environment: 13 | - REDIRECT_URL="http://localhost:3001/api/googleoauth/callback" 14 | -------------------------------------------------------------------------------- /src/app/app/upgrade/page.tsx: -------------------------------------------------------------------------------- 1 | import { Pricing } from "@/feature/pricing"; 2 | import { PricingTitles } from "@/feature/pricing/titles"; 3 | 4 | export default function Upgrade() { 5 | return ( 6 |
7 | 8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /src/utils/server/events.ts: -------------------------------------------------------------------------------- 1 | export function newVisitEvent(path: string, userfp: string) { 2 | return fetch("/api/events/visit", { 3 | method: "POST", 4 | body: JSON.stringify({ path: path, user: userfp }), 5 | }); 6 | } 7 | 8 | export function newErrorEvent(from: string, cause: string) { 9 | return fetch("/api/events/error", { 10 | method: "POST", 11 | body: JSON.stringify({ from: from, cause: cause }), 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/client/scroll-to-elem.ts: -------------------------------------------------------------------------------- 1 | export const scrollToElement = (elementId: string) => { 2 | const element = document.getElementById(elementId); 3 | if (element) { 4 | const offset = 100; // Отступ в пикселях 5 | const elementPosition = element.getBoundingClientRect().top; 6 | const offsetPosition = elementPosition + window.pageYOffset - offset; 7 | 8 | window.scrollTo({ 9 | top: offsetPosition, 10 | behavior: "smooth", 11 | }); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/server/date.ts: -------------------------------------------------------------------------------- 1 | export function Is30Days(dateStr: string) { 2 | const dateUTC = new Date(dateStr); 3 | 4 | // Получаем текущее время в UTC 5 | const nowUTC = new Date(new Date().toISOString()); 6 | // Вычисляем разницу в миллисекундах между текущей датой и указанной датой 7 | const diffMilliseconds = nowUTC.getTime() - dateUTC.getTime(); 8 | 9 | // Переводим разницу из миллисекунд в дни 10 | const daysDiff = diffMilliseconds / (1000 * 60 * 60 * 24); 11 | 12 | return daysDiff > 30; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/app/edit/[id]/_actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/db"; 4 | import { wordTable } from "@/db/schema"; 5 | import { Auth } from "@/utils/server/check-token"; 6 | import { and, eq } from "drizzle-orm"; 7 | import { revalidatePath } from "next/cache"; 8 | 9 | export async function DeleteWord(id: string) { 10 | const user = await Auth(); 11 | await db 12 | .update(wordTable) 13 | .set({ 14 | is_deleted: 1, 15 | }) 16 | .where(and(eq(wordTable.id, id), eq(wordTable.user_id, user.id))); 17 | 18 | revalidatePath("/app"); 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | data.db 39 | maindata.db 40 | 41 | -------------------------------------------------------------------------------- /src/feature/pricing/titles.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslations } from "next-intl"; 2 | 3 | export function PricingTitles() { 4 | const t = useTranslations("Pricing"); 5 | return ( 6 |
7 |

8 | {t.rich("Title", { 9 | br: () =>
, 10 | })} 11 |

12 | 13 | {t.rich("SubTitle", { 14 | br: () =>
, 15 | })} 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/app/edit/[id]/_delete.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTranslations } from "next-intl"; 4 | import { DeleteWord } from "./_actions"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | export function DeleteButton(props: { id: string }) { 8 | const t = useTranslations("Edit"); 9 | const router = useRouter(); 10 | return ( 11 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1722332299958, 9 | "tag": "0000_conscious_zuras", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "6", 15 | "when": 1722627316029, 16 | "tag": "0001_wooden_the_hand", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "6", 22 | "when": 1722937708576, 23 | "tag": "0002_lethal_bastion", 24 | "breakpoints": true 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/server/generateToken.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import jwt from "jsonwebtoken"; 3 | 4 | export const JWTSchema = z.object({ 5 | id: z.string().min(10), 6 | email: z.string().min(1), 7 | language: z.string().optional(), 8 | targets: z.array(z.string()), 9 | points_updated: z.string().optional(), 10 | plan: z.string(), 11 | }); 12 | 13 | type JWTDataType = z.infer; 14 | 15 | export function GenerateJWTToken(data: JWTDataType): string { 16 | const jwtkey = process.env.JWT_SECRET; 17 | if (!jwtkey) { 18 | return ""; 19 | } 20 | const token = jwt.sign(JSON.stringify(data), jwtkey); 21 | return token; 22 | } 23 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Исключить node_modules 2 | node_modules 3 | node_modules/ 4 | 5 | # Исключить директории логов и временных файлов 6 | logs 7 | *.log 8 | tmp 9 | *.tmp 10 | 11 | # Исключить файлы конфигурации, содержащие секреты 12 | .env 13 | 14 | # Исключить IDE и редактор файлов 15 | .vscode/ 16 | .idea/ 17 | *.swp 18 | 19 | # Исключить файлы Docker 20 | Dockerfile 21 | docker-compose.yml 22 | .dockerignore 23 | 24 | # Исключить директории, которые создаются сборочными процессами 25 | dist/ 26 | build/ 27 | coverage/ 28 | .next/ 29 | 30 | # Исключить файлы, связанные с тестированием 31 | *.spec.js 32 | *.test.js 33 | 34 | main.db 35 | sw.db 36 | maindata.db 37 | data.db 38 | -------------------------------------------------------------------------------- /src/i18n.ts: -------------------------------------------------------------------------------- 1 | import { getRequestConfig } from "next-intl/server"; 2 | import { cookies, headers } from "next/headers"; 3 | 4 | export default getRequestConfig(async () => { 5 | // Provide a static locale, fetch a user setting, 6 | // read from `cookies()`, `headers()`, etc. 7 | const cookieStore = cookies(); 8 | const acceptLang = headers() 9 | .get("accept-language") 10 | ?.split(",")[0] 11 | .slice(0, 2); 12 | const lang = cookieStore.get("APP_LOCALE"); 13 | console.log(lang?.value, acceptLang); 14 | const locale = lang?.value || acceptLang || "en"; 15 | 16 | return { 17 | locale, 18 | messages: (await import(`../messages/${locale}.json`)).default, 19 | }; 20 | }); 21 | -------------------------------------------------------------------------------- /src/feature/pricing/button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Crown } from "lucide-react"; 4 | import { useRouter } from "next/navigation"; 5 | import { ReactNode } from "react"; 6 | 7 | export function UpgradeButton({ 8 | children, 9 | path, 10 | }: { 11 | children: ReactNode; 12 | path?: string; 13 | }) { 14 | const router = useRouter(); 15 | function handleClick() { 16 | router.push(path || "/app"); 17 | } 18 | return ( 19 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/api/events/error/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { eventTable } from "@/db/schema"; 3 | import { ErrorSchema } from "@/utils/server/schemas"; 4 | import { v4 } from "uuid"; 5 | 6 | export const POST = async (req: Request) => { 7 | try { 8 | const res = await req.json(); 9 | const data = ErrorSchema.parse(res); 10 | await db.insert(eventTable).values({ 11 | id: v4(), 12 | type: "error", 13 | value: JSON.stringify(data), 14 | created_at: new Date().toISOString(), 15 | }); 16 | return new Response("success", { 17 | status: 200, 18 | }); 19 | } catch (error) { 20 | console.log(error); 21 | return new Response("error", { 22 | status: 400, 23 | }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const createNextIntlPlugin = require("next-intl/plugin"); 2 | 3 | const withNextIntl = createNextIntlPlugin(); 4 | 5 | /** @type {import('next').NextConfig} */ 6 | const nextConfig = { 7 | output: "standalone", 8 | pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"], 9 | eslint: { 10 | // Warning: This allows production builds to successfully complete even if 11 | // your project has ESLint errors. 12 | ignoreDuringBuilds: true, 13 | }, 14 | reactStrictMode: false, 15 | images: { 16 | remotePatterns: [ 17 | { 18 | protocol: "https", 19 | hostname: "lh3.googleusercontent.com", 20 | port: "", 21 | pathname: "/**", 22 | }, 23 | ], 24 | }, 25 | }; 26 | 27 | module.exports = withNextIntl(nextConfig); 28 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /src/app/api/googleoauth/login/route.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2Client } from "google-auth-library"; 2 | import { NextRequest } from "next/server"; 3 | 4 | export const dynamic = "force-dynamic"; 5 | export function GET(req: NextRequest) { 6 | const state = req.nextUrl.searchParams.get("state"); 7 | const loginUrl = new OAuth2Client({ 8 | clientId: process.env.GOOGLE_ID, 9 | clientSecret: process.env.GOOGLE_SECRET, 10 | redirectUri: process.env.REDIRECT_URL, 11 | }).generateAuthUrl({ 12 | access_type: "offline", 13 | scope: ["email", "profile"], 14 | state: state || "somestate", 15 | }); 16 | console.log(state); 17 | return new Response("", { 18 | status: 302, // Статус редиректа 19 | headers: { 20 | Location: loginUrl, // Новый URL для редиректа 21 | }, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ], 24 | "paths": { 25 | "@/*": [ 26 | "./src/*" 27 | ] 28 | } 29 | }, 30 | "include": [ 31 | "next-env.d.ts", 32 | "**/*.ts", 33 | "**/*.tsx", 34 | ".next/types/**/*.ts" 35 | ], 36 | "exclude": [ 37 | "node_modules" 38 | ] 39 | } -------------------------------------------------------------------------------- /src/utils/server/guesthash.ts: -------------------------------------------------------------------------------- 1 | export function addLetters(input: string): string { 2 | if (input.length < 2) { 3 | throw new Error( 4 | "Input string is too short to add letters at specified indices." 5 | ); 6 | } 7 | 8 | const firstPart = input.slice(0, 2) + "h"; 9 | const secondPart = input.slice(2, 5) + "3"; 10 | const thirdPart = input.slice(5); 11 | 12 | return firstPart + secondPart + thirdPart; 13 | } 14 | 15 | export function removeLetters(input: string): string { 16 | if (input.length < 7) { 17 | throw new Error( 18 | "Input string is too short to remove letters from specified indices." 19 | ); 20 | } 21 | 22 | const firstPart = input.slice(0, 2); 23 | const secondPart = input.slice(3, 5); 24 | const thirdPart = input.slice(6); 25 | 26 | return firstPart + secondPart + thirdPart; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/common/button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { ReactNode } from "react"; 5 | import { motion } from "framer-motion"; 6 | import { ChevronRightIcon } from "@radix-ui/react-icons"; 7 | import { useRouter } from "next/navigation"; 8 | 9 | export function CTAButton({ 10 | children, 11 | path, 12 | }: { 13 | children: ReactNode; 14 | path: string; 15 | }) { 16 | const router = useRouter(); 17 | return ( 18 | { 20 | router.push(path); 21 | }} 22 | animate={{ y: [-5, 0, -5] }} 23 | transition={{ repeat: Infinity, duration: 2 }} 24 | className="px-7 py-3 bg-white rounded-2xl border-violet-500 border flex items-center gap-2" 25 | > 26 | {children} 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/api/events/visit/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { eventTable } from "@/db/schema"; 3 | import { newErrorEvent } from "@/utils/server/events"; 4 | import { VisitSchema } from "@/utils/server/schemas"; 5 | import { v4 } from "uuid"; 6 | 7 | export const POST = async (req: Request) => { 8 | try { 9 | const res = await req.json(); 10 | const data = VisitSchema.parse(res); 11 | await db.insert(eventTable).values({ 12 | id: v4(), 13 | type: "visit", 14 | value: JSON.stringify({ path: data.path, user: data.user }), 15 | created_at: new Date().toISOString(), 16 | }); 17 | return new Response("success", { 18 | status: 200, 19 | }); 20 | } catch (error) { 21 | await newErrorEvent("event visit route", JSON.stringify(error)); 22 | return new Response("error", { 23 | status: 400, 24 | }); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/app/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { wordTable } from "@/db/schema"; 3 | import { HomePage } from "@/feature/home"; 4 | import { Auth } from "@/utils/server/check-token"; 5 | import { and, desc, eq } from "drizzle-orm"; 6 | 7 | export const dynamic = "force-dynamic"; 8 | 9 | export default async function Home() { 10 | const user = await Auth(); 11 | const words = await Promise.all( 12 | user.targets.map(async (targetlaguage) => { 13 | const targetWords = await db 14 | .select() 15 | .from(wordTable) 16 | .where( 17 | and( 18 | eq(wordTable.user_id, user.id), 19 | eq(wordTable.is_deleted, 0), 20 | eq(wordTable.to_language, targetlaguage) 21 | ) 22 | ) 23 | .orderBy(desc(wordTable.created_at)) 24 | .limit(10); 25 | return { 26 | language: targetlaguage, 27 | words: targetWords, 28 | }; 29 | }) 30 | ); 31 | return ; 32 | } 33 | -------------------------------------------------------------------------------- /src/feature/webscrap/index.tsx: -------------------------------------------------------------------------------- 1 | import { WordDescType } from "./worddesc"; 2 | 3 | export function ScrapLabels(props: { worddesc: WordDescType }) { 4 | return ( 5 |
6 | {props.worddesc.pos && ( 7 | 8 | {props.worddesc.pos} 9 | 10 | )} 11 | {props.worddesc.level && ( 12 | 13 | {props.worddesc.level} 14 | 15 | )} 16 | {props.worddesc.article && ( 17 | 18 | {props.worddesc.article} 19 | 20 | )} 21 | {props.worddesc.pronounce && ( 22 | 23 | {props.worddesc.pronounce} 24 | 25 | )} 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/server/system-langs.ts: -------------------------------------------------------------------------------- 1 | export const TargetLanguages = [ 2 | { 3 | value: "english", 4 | text: "English", 5 | icon: "english.png", 6 | }, 7 | { 8 | value: "german", 9 | text: "Deutsch", 10 | icon: "german.png", 11 | }, 12 | ]; 13 | 14 | export const OsLanguages: Language[] = [ 15 | { 16 | value: "russian", 17 | short: "RU", 18 | text: "Русский язык", 19 | icon: "russian.png", 20 | }, 21 | { 22 | value: "english", 23 | short: "EN", 24 | text: "English", 25 | icon: "english.png", 26 | }, 27 | { 28 | value: "french", 29 | short: "FR", 30 | text: "Français", 31 | icon: "french.png", 32 | }, 33 | { 34 | value: "turkish", 35 | short: "TR", 36 | text: "Türkçe", 37 | icon: "turkish.png", 38 | }, 39 | { 40 | value: "chinese", 41 | short: "ZH", 42 | text: "中文", 43 | icon: "chinese.png", 44 | }, 45 | ]; 46 | 47 | export type Language = { 48 | value: string; 49 | short: string; 50 | text: string; 51 | icon: string; 52 | }; 53 | -------------------------------------------------------------------------------- /public/mylogofav.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Auth } from "@/utils/server/check-token"; 2 | import { redirect } from "next/navigation"; 3 | import { Header } from "./_header"; 4 | import { db } from "@/db"; 5 | import { userTable } from "@/db/schema"; 6 | import { eq } from "drizzle-orm"; 7 | import { Is30Days } from "@/utils/server/date"; 8 | 9 | export const dynamic = "force-dynamic"; 10 | 11 | export default async function AppLayout({ 12 | children, 13 | }: Readonly<{ 14 | children: React.ReactNode; 15 | }>) { 16 | const user = await Auth(); 17 | const userFromDB = await db 18 | .select() 19 | .from(userTable) 20 | .where(eq(userTable.id, user.id)); 21 | if (userFromDB.length === 0) { 22 | redirect("/login"); 23 | } else if ( 24 | !user.language || 25 | user.targets.length === 0 || 26 | !user.points_updated 27 | ) { 28 | redirect("/onboard"); 29 | } 30 | if (Is30Days(user.points_updated)) { 31 | redirect("/api/update/point"); 32 | } 33 | return ( 34 | <> 35 |
36 | {children} 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/feature/home/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/db"; 4 | import { wordTable } from "@/db/schema"; 5 | import { Auth } from "@/utils/server/check-token"; 6 | import { and, desc, eq, like } from "drizzle-orm"; 7 | 8 | export async function SearchResultsAction(prefix: string) { 9 | const result = await Auth(); 10 | const words = await db 11 | .select() 12 | .from(wordTable) 13 | .where( 14 | and(like(wordTable.title, prefix + "%"), eq(wordTable.user_id, result.id)) 15 | ); 16 | return words; 17 | } 18 | 19 | export async function GetMoreWords( 20 | currentCount: number, 21 | targetLanguage: string, 22 | limit: number 23 | ) { 24 | const user = await Auth(); 25 | const words = await db 26 | .select() 27 | .from(wordTable) 28 | .where( 29 | and( 30 | eq(wordTable.user_id, user.id), 31 | eq(wordTable.is_deleted, 0), 32 | eq(wordTable.to_language, targetLanguage) 33 | ) 34 | ) 35 | .orderBy(desc(wordTable.created_at)) 36 | .limit(limit) 37 | .offset(currentCount); 38 | return words; 39 | } 40 | -------------------------------------------------------------------------------- /src/app/app/ask/[lang]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Auth } from "@/utils/server/check-token"; 2 | import { redirect } from "next/navigation"; 3 | import { cookies } from "next/headers"; 4 | import { Ask } from "@/feature/ask"; 5 | import { db } from "@/db"; 6 | import { pointTable } from "@/db/schema"; 7 | import { eq } from "drizzle-orm"; 8 | 9 | export const dynamic = "force-dynamic"; 10 | export default async function AskPage({ 11 | params, 12 | }: { 13 | params: { lang: string }; 14 | }) { 15 | const lang = params.lang; 16 | if (!lang) { 17 | redirect("/app"); 18 | } 19 | const result = await Auth(); 20 | const token = cookies().get("Authorization"); 21 | if (token === null || token?.value === undefined) { 22 | redirect("/app"); 23 | } 24 | const [points] = await db 25 | .select() 26 | .from(pointTable) 27 | .where(eq(pointTable.user_id, result.id)); 28 | if (!points.point || points.point <= 0) { 29 | redirect("/app/nopoints"); 30 | } 31 | return ( 32 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /public/myfav.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/feature/onboard/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/db"; 4 | import { languageTable, pointTable, userTable } from "@/db/schema"; 5 | import { Auth } from "@/utils/server/check-token"; 6 | import { GenerateJWTToken } from "@/utils/server/generateToken"; 7 | import { eq } from "drizzle-orm"; 8 | import { cookies } from "next/headers"; 9 | import { redirect } from "next/navigation"; 10 | import { v4 } from "uuid"; 11 | 12 | export async function UpdateLanguages(Os: string, Target: string[]) { 13 | const user = await Auth(); 14 | await db 15 | .update(userTable) 16 | .set({ 17 | language: Os, 18 | }) 19 | .where(eq(userTable.id, user.id)); 20 | await db.insert(languageTable).values( 21 | Target.map((t) => { 22 | return { id: v4(), name: t, user_id: user.id }; 23 | }) 24 | ); 25 | const token = GenerateJWTToken({ 26 | id: user.id, 27 | email: user.email, 28 | targets: Target, 29 | language: Os, 30 | points_updated: user.points_updated, 31 | plan: user.plan, 32 | }); 33 | cookies().set("Authorization", token); 34 | redirect("/app"); 35 | } 36 | 37 | export async function SetLocale(Lang: string) { 38 | cookies().set("APP_LOCALE", Lang); 39 | } 40 | -------------------------------------------------------------------------------- /src/feature/analytics/index.tsx: -------------------------------------------------------------------------------- 1 | // app/providers.js 2 | "use client"; 3 | import { generateFingerprint } from "@/utils/client/fingerprint"; 4 | import { newErrorEvent, newVisitEvent } from "@/utils/server/events"; 5 | import { usePathname, useSearchParams } from "next/navigation"; 6 | import { useEffect } from "react"; 7 | 8 | const routesToNotCount = ["/admin"]; 9 | 10 | export function AnalyticsCounter(): null { 11 | const pathname = usePathname(); 12 | const searchParams = useSearchParams(); 13 | async function sendVisit(url: string) { 14 | try { 15 | const fp = await generateFingerprint(); 16 | const res = await newVisitEvent(url, fp); 17 | if (!res.ok) { 18 | throw new Error("bad request"); 19 | } 20 | } catch (error) { 21 | await newErrorEvent("visit event component", JSON.stringify(error)); 22 | } 23 | } 24 | useEffect(() => { 25 | if (pathname) { 26 | let url = window.origin + pathname; 27 | if (routesToNotCount.includes(pathname)) { 28 | return; 29 | } 30 | if (searchParams.toString()) { 31 | url = url + `?${searchParams.toString()}`; 32 | } 33 | sendVisit(url); 34 | } 35 | }, [pathname, searchParams]); 36 | 37 | return null; 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/server/check-token.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import { redirect } from "next/navigation"; 3 | import { cookies } from "next/headers"; 4 | import { JWTSchema } from "./generateToken"; 5 | 6 | export function isAuthed() { 7 | const token = cookies().get("Authorization"); 8 | if (token === undefined) { 9 | return null; 10 | } 11 | const jwtkey = process.env.JWT_SECRET; 12 | if (jwtkey === undefined) { 13 | return null; 14 | } 15 | try { 16 | const cl = jwt.verify(token.value, jwtkey); 17 | const claims = JWTSchema.safeParse(cl); 18 | if (!claims.success) { 19 | return null; 20 | } 21 | return claims.data; 22 | } catch (error) { 23 | return null; 24 | } 25 | } 26 | 27 | export async function Auth() { 28 | const token = cookies().get("Authorization"); 29 | if (token === undefined) { 30 | redirect("/login"); 31 | } 32 | const jwtkey = process.env.JWT_SECRET; 33 | if (jwtkey === undefined) { 34 | redirect("/app"); 35 | } 36 | try { 37 | const cl = jwt.verify(token.value, jwtkey); 38 | const claims = JWTSchema.safeParse(cl); 39 | if (!claims.success) { 40 | redirect("/login"); 41 | } 42 | return claims.data; 43 | } catch (error) { 44 | redirect("/login"); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/update/point/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { pointTable } from "@/db/schema"; 3 | import { CalculatePoints } from "@/utils/server/calculate-points"; 4 | import { Auth } from "@/utils/server/check-token"; 5 | import { Is30Days } from "@/utils/server/date"; 6 | import { GenerateJWTToken } from "@/utils/server/generateToken"; 7 | import { eq } from "drizzle-orm"; 8 | 9 | export const dynamic = "force-dynamic"; 10 | export async function GET(req: Request) { 11 | const user = await Auth(); 12 | const Is30DaysAlready = Is30Days(user.points_updated || ""); 13 | if (Is30DaysAlready) { 14 | const today = new Date().toISOString(); 15 | await db 16 | .update(pointTable) 17 | .set({ 18 | point: CalculatePoints(user.plan), 19 | updated_at: today, 20 | }) 21 | .where(eq(pointTable.user_id, user.id)); 22 | const token = GenerateJWTToken({ 23 | ...user, 24 | points_updated: today, 25 | }); 26 | return new Response("", { 27 | status: 302, // Статус редиректа 28 | headers: { 29 | Location: "/app", // Новый URL для редиректа 30 | "Set-Cookie": `Authorization=${token}; Path=/;`, // Установка куки 31 | }, 32 | }); 33 | } else { 34 | return new Response("", { 35 | status: 302, // Статус редиректа 36 | headers: { 37 | Location: "/app", // Новый URL для редиректа 38 | }, 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Poppins } from "next/font/google"; 2 | import "./globals.css"; 3 | import { 4 | NextIntlClientProvider, 5 | useLocale, 6 | useMessages, 7 | useTranslations, 8 | } from "next-intl"; 9 | 10 | import { AnalyticsCounter } from "@/feature/analytics"; 11 | 12 | const inter = Poppins({ 13 | weight: ["500", "400", "700", "600", "300", "800", "900"], 14 | subsets: ["latin"], 15 | }); 16 | 17 | export default function RootLayout({ 18 | children, 19 | }: Readonly<{ 20 | children: React.ReactNode; 21 | }>) { 22 | const locale = useLocale(); 23 | const messages = useMessages(); 24 | const t = useTranslations("Meta"); 25 | return ( 26 | 27 | 28 | {t("Landing.Title")} 29 | 30 | 31 | 35 | 36 | 37 | 38 | 39 | 42 | 43 | {children} 44 | 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/app/app/word/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { wordTable } from "@/db/schema"; 3 | import { Pencil1Icon } from "@radix-ui/react-icons"; 4 | import { and, eq } from "drizzle-orm"; 5 | import { redirect } from "next/navigation"; 6 | import Link from "next/link"; 7 | import { RenderedWord } from "@/feature/render-word"; 8 | import { getWordDesc } from "@/feature/webscrap/worddesc"; 9 | import { ScrapLabels } from "@/feature/webscrap"; 10 | 11 | export default async function WordPage({ params }: { params: { id: string } }) { 12 | const [word] = await db 13 | .select() 14 | .from(wordTable) 15 | .where(and(eq(wordTable.id, params.id), eq(wordTable.is_deleted, 0))); 16 | if (!word || !word.title || !word.description) { 17 | redirect("/app"); 18 | } 19 | 20 | const worddesc = await getWordDesc(word.title, word.to_language || "english"); 21 | return ( 22 |
23 | 27 | 28 | 29 |

{word.title}

30 | 31 | 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /src/feature/local/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Select, 5 | SelectContent, 6 | SelectItem, 7 | SelectTrigger, 8 | SelectValue, 9 | } from "@/components/ui/select"; 10 | import { OsLanguages } from "@/utils/server/system-langs"; 11 | import { LocalChangeAction } from "./action"; 12 | import Image from "next/image"; 13 | import { useLocale, useTranslations } from "next-intl"; 14 | 15 | export function LocalChange() { 16 | const t = useTranslations("Locale"); 17 | const current = useLocale(); 18 | return ( 19 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/feature/home/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { wordTable } from "@/db/schema"; 4 | import { LanguageChange } from "./change-lang"; 5 | import { useState } from "react"; 6 | import { Card } from "./card"; 7 | 8 | export type WordType = typeof wordTable.$inferSelect; 9 | 10 | export function HomePage({ 11 | words, 12 | }: { 13 | words: { 14 | language: string; 15 | words: WordType[]; 16 | }[]; 17 | }) { 18 | const [currentLang, setLang] = useState(words[0].language); 19 | return ( 20 |
21 |
22 |
23 |
24 | 29 |
30 | card.language === currentLang)!} 33 | /> 34 |
35 |
36 |
37 | ); 38 | } 39 | 40 | function wordsByLanguage(words: WordType[], languages: string[]) { 41 | return languages.map((lang) => { 42 | return { 43 | language: lang, 44 | words: words.reduce((acc, word) => { 45 | if (word.to_language === lang) { 46 | acc.push(word); 47 | } 48 | return acc; 49 | }, []), 50 | }; 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /src/app/app/repeat/page.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { wordTable } from "@/db/schema"; 3 | import { Repeat } from "@/feature/repeat"; 4 | import { Auth } from "@/utils/server/check-token"; 5 | import { and, eq } from "drizzle-orm"; 6 | import { redirect } from "next/navigation"; 7 | 8 | export const dynamic = "force-dynamic"; 9 | export default async function RepeatPage({ 10 | searchParams, 11 | }: { 12 | searchParams?: { count?: string; language?: string }; 13 | }) { 14 | const count = searchParams?.count; 15 | const lang = searchParams?.language; 16 | if (!count || !lang || isNaN(Number(count))) { 17 | redirect("/app"); 18 | } 19 | const result = await Auth(); 20 | const words = await db 21 | .select() 22 | .from(wordTable) 23 | .where( 24 | and( 25 | eq(wordTable.user_id, result.id), 26 | eq(wordTable.to_language, lang), 27 | eq(wordTable.is_deleted, 0) 28 | ) 29 | ); 30 | if (words.length === 0) { 31 | redirect("/app"); 32 | } 33 | const generatedWords = getRandomElements(words, Number(count)); 34 | return ; 35 | } 36 | 37 | function getRandomElements(array: T[], n: number): T[] { 38 | if (n > array.length) { 39 | n = array.length; 40 | } 41 | 42 | const result: T[] = []; 43 | const usedIndices = new Set(); 44 | 45 | while (result.length < n) { 46 | const randomIndex = Math.floor(Math.random() * array.length); 47 | if (!usedIndices.has(randomIndex)) { 48 | usedIndices.add(randomIndex); 49 | result.push(array[randomIndex]); 50 | } 51 | } 52 | 53 | return result; 54 | } 55 | -------------------------------------------------------------------------------- /src/feature/landing/stats.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslations } from "next-intl"; 2 | import { JSXElementConstructor, ReactElement, ReactNodeArray } from "react"; 3 | 4 | export const LandingStats = () => { 5 | const t = useTranslations("Landing.Stats"); 6 | return ( 7 |
11 |
, 16 | })} 17 | /> 18 |
, 23 | })} 24 | /> 25 |
, 30 | })} 31 | /> 32 |
33 | ); 34 | }; 35 | 36 | const Stat = ({ 37 | main, 38 | label, 39 | desc, 40 | }: { 41 | main: string; 42 | label: string; 43 | desc: 44 | | string 45 | | ReactElement> 46 | | ReactNodeArray; 47 | }) => { 48 | return ( 49 |
50 |
51 | {main} 52 | {label} 53 |
54 |

{desc}

55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /drizzle/0000_conscious_zuras.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `events` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `type` text NOT NULL, 4 | `value` text, 5 | `created_at` text DEFAULT CURRENT_TIMESTAMP 6 | ); 7 | --> statement-breakpoint 8 | CREATE TABLE `languages` ( 9 | `id` text PRIMARY KEY NOT NULL, 10 | `name` text NOT NULL, 11 | `user_id` text, 12 | `created_at` text DEFAULT CURRENT_TIMESTAMP, 13 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action 14 | ); 15 | --> statement-breakpoint 16 | CREATE TABLE `points` ( 17 | `id` text PRIMARY KEY NOT NULL, 18 | `point` integer DEFAULT 0, 19 | `user_id` text, 20 | `created_at` text DEFAULT CURRENT_TIMESTAMP, 21 | `last_updated` text DEFAULT CURRENT_TIMESTAMP, 22 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action 23 | ); 24 | --> statement-breakpoint 25 | CREATE TABLE `users` ( 26 | `id` text PRIMARY KEY NOT NULL, 27 | `name` text NOT NULL, 28 | `full_name` text, 29 | `email` text NOT NULL, 30 | `avatar` text, 31 | `language` text, 32 | `created_at` text DEFAULT CURRENT_TIMESTAMP, 33 | `role` text DEFAULT 'free' 34 | ); 35 | --> statement-breakpoint 36 | CREATE TABLE `words` ( 37 | `id` text PRIMARY KEY NOT NULL, 38 | `title` text NOT NULL, 39 | `description` text, 40 | `from_language` text, 41 | `to_language` text, 42 | `type` text, 43 | `is_deleted` integer DEFAULT 0, 44 | `user_id` text, 45 | `created_at` text DEFAULT CURRENT_TIMESTAMP, 46 | `updated_at` text DEFAULT CURRENT_TIMESTAMP, 47 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action 48 | ); 49 | --> statement-breakpoint 50 | CREATE UNIQUE INDEX `points_user_id_unique` ON `points` (`user_id`);--> statement-breakpoint 51 | CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`); -------------------------------------------------------------------------------- /src/app/api/update/role/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { pointTable, userTable } from "@/db/schema"; 3 | import { isAuthed } from "@/utils/server/check-token"; 4 | import { newErrorEvent } from "@/utils/server/events"; 5 | import { GenerateJWTToken } from "@/utils/server/generateToken"; 6 | import { eq } from "drizzle-orm"; 7 | import { redirect } from "next/navigation"; 8 | 9 | export const dynamic = "force-dynamic"; 10 | export const GET = async () => { 11 | try { 12 | const userauth = isAuthed(); 13 | if (!userauth) { 14 | redirect("/app"); 15 | } 16 | const [user] = await db 17 | .select() 18 | .from(userTable) 19 | .where(eq(userTable.id, userauth.id)); 20 | const [points] = await db 21 | .select() 22 | .from(pointTable) 23 | .where(eq(pointTable.user_id, userauth.id)); 24 | if ( 25 | userauth.plan !== user.role || 26 | userauth.points_updated !== points.updated_at 27 | ) { 28 | const jwttoken = GenerateJWTToken({ 29 | id: user.id, 30 | email: user.email, 31 | language: user.language!, 32 | plan: user.role!, 33 | targets: userauth.targets, 34 | points_updated: points.updated_at!, 35 | }); 36 | 37 | return new Response("", { 38 | status: 302, // Статус редиректа 39 | headers: { 40 | Location: "/app", // Новый URL для редиректа 41 | "Set-Cookie": `Authorization=${jwttoken}; Path=/; HttpOnly`, // Установка куки 42 | }, 43 | }); 44 | } 45 | } catch (error) { 46 | await newErrorEvent("role route", JSON.stringify(error)); 47 | return new Response("", { 48 | status: 302, // Статус редиректа 49 | headers: { 50 | Location: "/app", // Новый URL для редиректа 51 | }, 52 | }); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /src/feature/landing/faq.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ChevronDownIcon } from "@radix-ui/react-icons"; 4 | import { motion } from "framer-motion"; 5 | import { useTranslations } from "next-intl"; 6 | 7 | import { useState } from "react"; 8 | 9 | export const LandingFAQ = () => { 10 | const t = useTranslations("Landing.FAQ"); 11 | return ( 12 |
13 |

{t("Title")}

14 |
15 | 16 | 17 | 18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | function Question(props: { question: string; answer: string }) { 25 | const [isOpen, setOpen] = useState(false); 26 | return ( 27 |
28 |
29 |
{props.question}
30 | setOpen((prev) => !prev)} 34 | /> 35 |
36 | 37 | {isOpen && ( 38 | 49 | {props.answer} 50 | 51 | )} 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/feature/webscrap/worddesc.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { wordDescTable } from "@/db/schema"; 3 | import { eq } from "drizzle-orm"; 4 | import { v4 } from "uuid"; 5 | import { z } from "zod"; 6 | 7 | const WordDescSchema = z.object({ 8 | pos: z.string(), 9 | level: z.string(), 10 | article: z.string(), 11 | pronounce: z.string(), 12 | }); 13 | 14 | export type WordDescType = z.infer; 15 | 16 | export async function getWordDesc(word: string, lang: string) { 17 | const obj: WordDescType = { 18 | pos: "", 19 | level: "", 20 | article: "", 21 | pronounce: "", 22 | }; 23 | const wordDesc = await db 24 | .select() 25 | .from(wordDescTable) 26 | .where(eq(wordDescTable.wordTitle, word.toLocaleLowerCase())); 27 | if (wordDesc.length === 0) { 28 | const res = await ( 29 | await fetch(process.env.SCRAP_URL + `?lang=${lang}&word=${word}`) 30 | ).json(); 31 | const worddescobj = WordDescSchema.safeParse(res); 32 | if (worddescobj.success) { 33 | obj.pos = worddescobj.data.pos; 34 | obj.level = worddescobj.data.level; 35 | obj.article = worddescobj.data.article; 36 | obj.pronounce = worddescobj.data.pronounce; 37 | 38 | await db 39 | .insert(wordDescTable) 40 | .values({ 41 | id: v4(), 42 | wordTitle: word.toLocaleLowerCase(), 43 | toLanguage: lang, 44 | pos: obj.pos, 45 | level: obj.level, 46 | article: obj.article, 47 | pronounce: obj.pronounce, 48 | }) 49 | .execute(); 50 | } 51 | return obj; 52 | } 53 | 54 | obj.article = wordDesc[0].article || ""; 55 | obj.level = wordDesc[0].level || ""; 56 | obj.pos = wordDesc[0].pos || ""; 57 | obj.pronounce = wordDesc[0].pronounce || ""; 58 | return obj; 59 | } 60 | -------------------------------------------------------------------------------- /src/app/app/nopoints/page.tsx: -------------------------------------------------------------------------------- 1 | import { CTAButton } from "@/components/common/button"; 2 | import { db } from "@/db"; 3 | import { pointTable } from "@/db/schema"; 4 | import { Auth } from "@/utils/server/check-token"; 5 | import { eq } from "drizzle-orm"; 6 | import { getTranslations } from "next-intl/server"; 7 | import Link from "next/link"; 8 | 9 | export const dynamic = "force-dynamic"; 10 | export default async function Nopoints() { 11 | const t = await getTranslations("NoPoints"); 12 | const result = await Auth(); 13 | const [points] = await db 14 | .select() 15 | .from(pointTable) 16 | .where(eq(pointTable.user_id, result.id)); 17 | return ( 18 |
19 |
20 |

21 | {t.rich("Title", { 22 | br: () =>
, 23 | })} 24 |

25 | 26 | {t.rich("Desc", { 27 | br: () =>
, 28 | })} 29 |
30 |
31 | {t("Upgrade")} 32 | 33 | {t("Next")} 34 | 35 | {formatDateWithAddedMonth(new Date(points.updated_at!))} 36 | 37 | 38 |
39 | ); 40 | } 41 | 42 | function formatDateWithAddedMonth(date: Date): string { 43 | date.setUTCMonth(date.getUTCMonth() + 1); // Увеличиваем месяц на один 44 | const day = date.getUTCDate().toString().padStart(2, "0"); 45 | const month = (date.getUTCMonth() + 1).toString().padStart(2, "0"); // Месяцы начинаются с 0, поэтому добавляем 1 46 | return `${day}.${month}`; 47 | } 48 | -------------------------------------------------------------------------------- /src/feature/landing/howworks.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LetterCaseCapitalizeIcon, 3 | LoopIcon, 4 | MagicWandIcon, 5 | MixIcon, 6 | } from "@radix-ui/react-icons"; 7 | import { IconProps } from "@radix-ui/react-icons/dist/types"; 8 | import { useTranslations } from "next-intl"; 9 | import { ForwardRefExoticComponent, RefAttributes } from "react"; 10 | 11 | export function HowStart() { 12 | const t = useTranslations("Landing.Works"); 13 | return ( 14 |
18 |

19 | {t.rich("Title", { 20 | br: () =>
, 21 | })} 22 |

23 |
24 | 25 | 26 | 27 | 28 |
29 |
30 | ); 31 | } 32 | 33 | export function Step({ 34 | num, 35 | text, 36 | Icon, 37 | }: { 38 | num: number; 39 | text: string; 40 | Icon: ForwardRefExoticComponent>; 41 | }) { 42 | return ( 43 |
44 | 45 | {num} 46 | 47 | 48 | {text} 49 | 50 |
51 | 52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/server/format-data.ts: -------------------------------------------------------------------------------- 1 | export function formatDate(isoDateString: string): string { 2 | // Создаем объект Date из строки в формате ISO 8601 3 | const date = new Date(isoDateString); 4 | 5 | // Получаем часы и минуты 6 | const hours = date.getUTCHours().toString().padStart(2, "0"); 7 | const minutes = date.getUTCMinutes().toString().padStart(2, "0"); 8 | 9 | // Получаем день 10 | const day = date.getUTCDate(); 11 | 12 | // Массив с названиями месяцев 13 | const monthNames = [ 14 | "January", 15 | "February", 16 | "March", 17 | "April", 18 | "May", 19 | "June", 20 | "July", 21 | "August", 22 | "September", 23 | "October", 24 | "November", 25 | "December", 26 | ]; 27 | 28 | // Получаем название месяца 29 | const month = monthNames[date.getUTCMonth()]; 30 | 31 | // Получаем год 32 | const year = date.getUTCFullYear(); 33 | 34 | // Форматируем строку в требуемом формате 35 | return `${hours}:${minutes} ${day} ${month} ${year}`; 36 | } 37 | 38 | export function extractPath(url: string): string { 39 | // Используем URL-конструктор для разбора строки URL 40 | try { 41 | const parsedUrl = new URL(url); 42 | return parsedUrl.pathname + parsedUrl.search + parsedUrl.hash; 43 | } catch (e) { 44 | // Если строка не является валидным URL, выбрасываем ошибку 45 | throw new Error("Invalid URL"); 46 | } 47 | } 48 | 49 | export function countPath(paths: string[]) { 50 | const result: { 51 | [key: string]: number; 52 | } = {}; 53 | for (let i = 0; i < paths.length; i++) { 54 | const currentpath = extractPath(paths[i]); 55 | if (currentpath in result) { 56 | result[currentpath]++; 57 | } else { 58 | result[currentpath] = 1; 59 | } 60 | } 61 | 62 | return Object.entries(result) 63 | .map((c) => { 64 | return { 65 | path: c[0], 66 | count: c[1], 67 | }; 68 | }) 69 | .sort((a, b) => b.count - a.count); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swfront_next", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "gen": "drizzle-kit generate", 11 | "push": "drizzle-kit push", 12 | "studio": "drizzle-kit studio" 13 | }, 14 | "dependencies": { 15 | "@ai-sdk/openai": "^0.0.36", 16 | "@libsql/client": "^0.7.0", 17 | "@mdx-js/loader": "^3.0.1", 18 | "@mdx-js/react": "^3.0.1", 19 | "@next/mdx": "^14.2.5", 20 | "@radix-ui/react-dialog": "^1.1.1", 21 | "@radix-ui/react-dropdown-menu": "^2.1.1", 22 | "@radix-ui/react-icons": "^1.3.0", 23 | "@radix-ui/react-select": "^2.1.1", 24 | "@radix-ui/react-slot": "^1.1.0", 25 | "@stripe/stripe-js": "^4.1.0", 26 | "@types/mdx": "^2.0.13", 27 | "@types/uuid": "^10.0.0", 28 | "@uidotdev/usehooks": "^2.4.1", 29 | "ai": "^3.2.27", 30 | "better-sqlite3": "^11.1.2", 31 | "class-variance-authority": "^0.7.0", 32 | "clsx": "^2.1.1", 33 | "dotenv": "^16.4.5", 34 | "drizzle-orm": "^0.32.0", 35 | "ehto": "^0.1.1", 36 | "embla-carousel-react": "^8.1.7", 37 | "framer-motion": "^11.3.8", 38 | "google-auth-library": "^9.11.0", 39 | "jsonwebtoken": "^9.0.2", 40 | "lucide-react": "^0.408.0", 41 | "next": "14.2.5", 42 | "next-intl": "^3.17.1", 43 | "next-mdx-remote": "^5.0.0", 44 | "openai": "^4.52.7", 45 | "posthog-js": "^1.148.2", 46 | "react": "^18", 47 | "react-dom": "^18", 48 | "server-only": "^0.0.1", 49 | "sharp": "^0.33.4", 50 | "stripe": "^16.5.0", 51 | "tailwind-merge": "^2.4.0", 52 | "tailwindcss-animate": "^1.0.7", 53 | "zod": "^3.23.8" 54 | }, 55 | "devDependencies": { 56 | "@types/better-sqlite3": "^7.6.11", 57 | "@types/jsonwebtoken": "^9.0.6", 58 | "@types/node": "^20", 59 | "@types/react": "^18", 60 | "@types/react-dom": "^18", 61 | "drizzle-kit": "^0.23.0", 62 | "eslint": "^8", 63 | "eslint-config-next": "14.2.5", 64 | "postcss": "^8", 65 | "tailwindcss": "^3.4.1", 66 | "typescript": "^5" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS base 2 | 3 | # Install dependencies only when needed 4 | FROM base AS deps 5 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 6 | RUN apk add --no-cache libc6-compat 7 | WORKDIR /app 8 | 9 | # Install dependencies based on the preferred package manager 10 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ 11 | RUN \ 12 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 13 | elif [ -f package-lock.json ]; then npm ci; \ 14 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ 15 | else echo "Lockfile not found." && exit 1; \ 16 | fi 17 | 18 | # Rebuild the source code only when needed 19 | FROM base AS builder 20 | WORKDIR /app 21 | COPY --from=deps /app/node_modules ./node_modules 22 | COPY . . 23 | 24 | # Next.js collects completely anonymous telemetry data about general usage. 25 | # Learn more here: https://nextjs.org/telemetry 26 | # Uncomment the following line in case you want to disable telemetry during the build. 27 | # ENV NEXT_TELEMETRY_DISABLED 1 28 | 29 | RUN \ 30 | if [ -f yarn.lock ]; then yarn run build; \ 31 | elif [ -f package-lock.json ]; then npm run build; \ 32 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ 33 | else echo "Lockfile not found." && exit 1; \ 34 | fi 35 | 36 | # Production image, copy all the files and run next 37 | FROM base AS runner 38 | WORKDIR /app 39 | 40 | ENV NODE_ENV production 41 | # Uncomment the following line in case you want to disable telemetry during runtime. 42 | # ENV NEXT_TELEMETRY_DISABLED 1 43 | 44 | COPY --from=builder /app/public ./public 45 | COPY --from=builder /app/messages ./messages 46 | COPY --from=builder /app/drizzle ./drizzle 47 | 48 | # Set the correct permission for prerender cache 49 | RUN mkdir .next 50 | RUN chmod 755 .next 51 | 52 | # Automatically leverage output traces to reduce image size 53 | # https://nextjs.org/docs/advanced-features/output-file-tracing 54 | COPY --from=builder /app/.next/standalone ./ 55 | COPY --from=builder /app/.next/static ./.next/static 56 | 57 | EXPOSE 3000 58 | 59 | ENV PORT 3000 60 | 61 | # server.js is created by next build from the standalone output 62 | # https://nextjs.org/docs/pages/api-reference/next-config-js/output 63 | CMD HOSTNAME="0.0.0.0" node server.js 64 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{ts,tsx}", 7 | "./components/**/*.{ts,tsx}", 8 | "./app/**/*.{ts,tsx}", 9 | "./src/**/*.{ts,tsx}", 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "1rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | } satisfies Config; 79 | 80 | export default config; 81 | -------------------------------------------------------------------------------- /src/app/api/guest/route.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | import { z } from "zod"; 3 | import jwt from "jsonwebtoken"; 4 | import { getPrompts } from "@/utils/server/prompt"; 5 | import { JWTSchema } from "@/utils/server/generateToken"; 6 | import { db } from "@/db"; 7 | import { guestWordsTable, pointTable, wordTable } from "@/db/schema"; 8 | import { eq } from "drizzle-orm"; 9 | import { removeLetters } from "@/utils/server/guesthash"; 10 | import { v4 } from "uuid"; 11 | 12 | const MessageSchema = z.object({ 13 | from: z.string(), 14 | to: z.string(), 15 | word: z.string(), 16 | token: z.string(), 17 | }); 18 | export const dynamic = "force-dynamic"; 19 | export async function POST(req: Request) { 20 | try { 21 | const openai = new OpenAI({ 22 | apiKey: process.env.OPENAI_API_KEY, 23 | }); 24 | const request = await req.json(); 25 | const body = MessageSchema.parse(request); 26 | const fp = removeLetters(body.token); 27 | const guestWords = await db 28 | .select() 29 | .from(guestWordsTable) 30 | .where(eq(guestWordsTable.guestID, fp)); 31 | if (guestWords.length !== 0) { 32 | return new Response( 33 | JSON.stringify({ 34 | title: guestWords[0].title, 35 | desc: guestWords[0].description, 36 | }), 37 | { 38 | status: 203, 39 | } 40 | ); 41 | } 42 | const response = await openai.chat.completions.create({ 43 | model: "gpt-4o-mini", 44 | stream: true, 45 | messages: [ 46 | { 47 | role: "user", 48 | content: getPrompts(body.from, body.to, body.word), 49 | }, 50 | ], 51 | }); 52 | let result = ""; 53 | const encoder = new TextEncoder(); 54 | const stream = new ReadableStream({ 55 | async start(controller) { 56 | for await (const chunk of response) { 57 | const ctext = chunk.choices[0]?.delta?.content || ""; 58 | controller.enqueue(encoder.encode(ctext)); 59 | result = result + ctext; 60 | } 61 | controller.close(); 62 | await db.insert(guestWordsTable).values({ 63 | id: v4(), 64 | title: body.word, 65 | description: result, 66 | fromLanguage: body.from, 67 | toLanguage: body.to, 68 | guestID: fp, 69 | }); 70 | }, 71 | }); 72 | 73 | return new Response(stream, { 74 | headers: { "Content-Type": "text/event-stream" }, 75 | }); 76 | } catch (error) { 77 | return new Response("", { 78 | status: 400, 79 | }); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/feature/landing/header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { MainLogo } from "@/components/icons/logo"; 4 | import { LocalChange } from "../local"; 5 | import { scrollToElement } from "@/utils/client/scroll-to-elem"; 6 | import { ReactNode } from "react"; 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu"; 13 | import { useTranslations } from "next-intl"; 14 | 15 | export const LandingHeader = () => { 16 | const t = useTranslations("Landing.Header"); 17 | return ( 18 |
19 |
20 | 21 | 27 | 28 |
29 | 30 |
31 |
32 |
33 | ); 34 | }; 35 | 36 | const Menu = () => { 37 | const t = useTranslations("Landing.Header"); 38 | return ( 39 | 40 | 41 |
42 | Menu 43 |
44 |
45 | 46 | 47 | {t("Over")} 48 | 49 | 50 | {t("Works")} 51 | 52 | 53 | {t("Try")} 54 | 55 | 56 | {t("Price")} 57 | 58 | 59 | 60 |
61 | ); 62 | }; 63 | 64 | const ScrollButton = (props: { 65 | id: string; 66 | children: ReactNode; 67 | className?: string; 68 | }) => { 69 | return ( 70 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { sql } from "drizzle-orm"; 2 | import { text, sqliteTable, int } from "drizzle-orm/sqlite-core"; 3 | 4 | export const userTable = sqliteTable("users", { 5 | id: text("id").primaryKey(), 6 | name: text("name").notNull(), 7 | full_name: text("full_name"), 8 | email: text("email").notNull().unique(), 9 | avatar: text("avatar"), 10 | language: text("language"), 11 | created_at: text("created_at").default(sql`CURRENT_TIMESTAMP`), 12 | role: text("role").default("free"), 13 | }); 14 | 15 | export const wordTable = sqliteTable("words", { 16 | id: text("id").primaryKey(), 17 | title: text("title").notNull(), 18 | description: text("description"), 19 | from_language: text("from_language"), 20 | to_language: text("to_language"), 21 | type: text("type"), 22 | is_deleted: int("is_deleted").default(0), 23 | user_id: text("user_id").references(() => userTable.id), 24 | created_at: text("created_at").default(sql`CURRENT_TIMESTAMP`), 25 | updated_at: text("updated_at").default(sql`CURRENT_TIMESTAMP`), 26 | }); 27 | 28 | export const wordDescTable = sqliteTable("word_descs", { 29 | id: text("id").primaryKey(), 30 | wordTitle: text("word_title"), 31 | toLanguage: text("to_language"), 32 | pos: text("pos"), 33 | level: text("level"), 34 | pronounce: text("pronounce"), 35 | article: text("article"), 36 | createdAt: text("created_at").default(sql`CURRENT_TIMESTAMP`), 37 | }); 38 | 39 | export const languageTable = sqliteTable("languages", { 40 | id: text("id").primaryKey(), 41 | name: text("name").notNull(), 42 | user_id: text("user_id").references(() => userTable.id), 43 | created_at: text("created_at").default(sql`CURRENT_TIMESTAMP`), 44 | }); 45 | 46 | export const pointTable = sqliteTable("points", { 47 | id: text("id").primaryKey(), 48 | point: int("point").default(0), 49 | user_id: text("user_id") 50 | .unique() 51 | .references(() => userTable.id), 52 | created_at: text("created_at").default(sql`CURRENT_TIMESTAMP`), 53 | updated_at: text("last_updated").default(sql`CURRENT_TIMESTAMP`), 54 | }); 55 | 56 | export const eventTable = sqliteTable("events", { 57 | id: text("id").primaryKey(), 58 | type: text("type").notNull(), 59 | value: text("value"), 60 | created_at: text("created_at").default(sql`CURRENT_TIMESTAMP`), 61 | }); 62 | 63 | export const guestWordsTable = sqliteTable("guest_word", { 64 | id: text("id").primaryKey(), 65 | title: text("title").notNull(), 66 | description: text("description"), 67 | fromLanguage: text("from_language"), 68 | toLanguage: text("to_language"), 69 | guestID: text("guest_id"), 70 | createdAt: text("created_at").default(sql`CURRENT_TIMESTAMP`), 71 | }); 72 | -------------------------------------------------------------------------------- /src/app/app/edit/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { wordTable } from "@/db/schema"; 3 | import { Auth } from "@/utils/server/check-token"; 4 | import { and, eq } from "drizzle-orm"; 5 | import { getTranslations } from "next-intl/server"; 6 | import { DeleteButton } from "./_delete"; 7 | import { redirect } from "next/navigation"; 8 | 9 | export const dynamic = "force-dynamic"; 10 | export default async function EditPage({ params }: { params: { id: string } }) { 11 | const t = await getTranslations("Edit"); 12 | const id = params.id; 13 | const result = await Auth(); 14 | async function hnd(formdata: FormData) { 15 | "use server"; 16 | const title = formdata.get("title"); 17 | const desc = formdata.get("desc"); 18 | if (title === null || desc === null) { 19 | return; 20 | } 21 | await db 22 | .update(wordTable) 23 | .set({ 24 | title: title.toString(), 25 | description: desc.toString(), 26 | updated_at: new Date().toISOString(), 27 | }) 28 | .where(and(eq(wordTable.id, id), eq(wordTable.user_id, result.id))); 29 | redirect("/app/word/" + id); 30 | } 31 | const [word] = await db 32 | .select() 33 | .from(wordTable) 34 | .where( 35 | and( 36 | eq(wordTable.id, id), 37 | eq(wordTable.user_id, result.id), 38 | eq(wordTable.is_deleted, 0) 39 | ) 40 | ); 41 | 42 | if (!word) { 43 | redirect("/app"); 44 | } 45 | 46 | return ( 47 |
51 |
52 | {t("Title")} 53 | 60 |
61 |
62 | {t("Desc")} 63 |