diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index d50e58d..3bd666b 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,4 +1,4 @@ -name: Deploy LetUsDev +name: One-off Deploy on: workflow_dispatch: @@ -11,12 +11,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20 -<<<<<<< Updated upstream - # - run: npm install -g pnpm - # - run: pnpm ci - # - run: pnpm test -======= - - name: Cache Next.js Build uses: actions/cache@v4 with: @@ -50,4 +44,3 @@ jobs: - name: Clean up AWS Profile run: rm -rf ~/.aws ->>>>>>> Stashed changes diff --git a/.github/workflows/stage.yaml b/.github/workflows/stage.yaml new file mode 100644 index 0000000..f899932 --- /dev/null +++ b/.github/workflows/stage.yaml @@ -0,0 +1,49 @@ +name: Stage Deploy via SST + +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Cache Next.js Build + uses: actions/cache@v4 + with: + path: | + .next/ + .open-next/ + .sst/ + key: cache-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**.[jt]s', '**.[jt]xs') }} + restore-keys: | + cache-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install dependencies + run: pnpm install + + - name: Install AWS Creds + run: | + mkdir -p ~/.aws + echo "[default]" > ~/.aws/credentials + echo "aws_access_key_id=${{ secrets.AWS_ACCESS_KEY_ID }}" >> ~/.aws/credentials + echo "aws_secret_access_key=${{ secrets.AWS_SECRET_ACCESS_KEY }}" >> ~/.aws/credentials + + - name: Set SST Config Secret + run: | + npx sst secret set DATABASE_URL ${{ secrets.DATABASE_URL_STAGING }} --stage staging + npx sst secret set NEXT_PUBLIC_URL ${{ secrets.NEXT_PUBLIC_URL_STAGING }} --stage staging + npx sst secret set COMMIT_SHA ${{ github.sha }} --stage staging + + - name: Deploy with SST + run: pnpm run deploy:staging + + - name: Clean up AWS Profile + run: rm -rf ~/.aws diff --git a/README.md b/README.md index b1d9bf4..feb9db9 100644 --- a/README.md +++ b/README.md @@ -92,5 +92,5 @@ sst secret set NEXT_PUBLIC_URL --env ## Contributing 1. Fork the repository -2. Create a feature branch +2. Create a feature branch or create an issue 3. Submit a pull request diff --git a/actions/blog/getBlog.ts b/actions/blog/getBlog.ts new file mode 100644 index 0000000..2af3499 --- /dev/null +++ b/actions/blog/getBlog.ts @@ -0,0 +1,25 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { blogsTable } from "@/lib/schema"; + +export async function getBlog(slug: string) { + try { + const incomingSlug = slug.replace(/^\/+|\/+$/g, ""); + + const blogData = await db + .select() + .from(blogsTable) + .where(eq(blogsTable.slug, `/${incomingSlug}`)); + + if (!blogData.length) { + throw new Error("Blog not found"); + } + + return blogData[0]; + } catch (error) { + console.error("Error fetching blog:", error); + throw new Error("Failed to fetch blog"); + } +} diff --git a/actions/blog/getBlogs.ts b/actions/blog/getBlogs.ts new file mode 100644 index 0000000..f253b42 --- /dev/null +++ b/actions/blog/getBlogs.ts @@ -0,0 +1,13 @@ +"use server"; +import { db } from "@/lib/db"; +import { blogsTable } from "@/lib/schema"; + +export async function getBlogs() { + try { + const blogs = await db.select().from(blogsTable).execute(); + return blogs; + } catch (error) { + console.error("Error fetching blogs:", error); + throw new Error("Failed to fetch blogs"); + } +} diff --git a/actions/work/getWorkExperience.ts b/actions/work/getWorkExperience.ts new file mode 100644 index 0000000..8fbd5b8 --- /dev/null +++ b/actions/work/getWorkExperience.ts @@ -0,0 +1,18 @@ +"use server"; + +import { db } from "../../lib/db"; +import { workExperienceTable } from "../../lib/schema"; + +export async function getWorkExperience() { + try { + const workExperience = await db + .select() + .from(workExperienceTable) + .orderBy(workExperienceTable.startDate); + + return workExperience; + } catch (error) { + console.error("Error fetching work experience:", error); + throw new Error("Failed to fetch work experience"); + } +} diff --git a/app/api/blog/[slug]/route.ts b/app/api/blog/[slug]/route.ts deleted file mode 100644 index 67c3ff6..0000000 --- a/app/api/blog/[slug]/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { eq } from "drizzle-orm"; -import { NextResponse } from "next/server"; -import { db } from "@/lib/db"; -import { blogsTable } from "@/lib/schema"; - -export async function GET( - request: Request, - { params }: { params: { slug: string } } -) { - try { - const incomingSlug = params.slug.replace(/^\/+|\/+$/g, ""); - - const blogData = await db - .select() - .from(blogsTable) - .where(eq(blogsTable.slug, `/${incomingSlug}`)); - - if (!blogData.length) { - return NextResponse.json({ error: "Blog not found" }, { status: 404 }); - } - - return NextResponse.json({ data: blogData[0] }); - } catch (error) { - console.error("Error fetching blog:", error); - return NextResponse.json( - { error: "Failed to fetch blog" }, - { status: 500 } - ); - } -} diff --git a/app/api/blog/route.ts b/app/api/blog/route.ts deleted file mode 100644 index 30c79d7..0000000 --- a/app/api/blog/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { db } from "@/lib/db"; -import { blogsTable } from "@/lib/schema"; -import { NextResponse } from "next/server"; -export const fetchCache = "force-no-store"; -export const revalidate = 0; - -export async function GET() { - const data = await db.select().from(blogsTable).execute(); - return NextResponse.json({ data }); -} diff --git a/app/api/migration/route.ts b/app/api/migration/route.ts deleted file mode 100644 index 615ed3e..0000000 --- a/app/api/migration/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { execSync } from "child_process"; - -export async function GET(request: Request) { - execSync(`npm run db push`, { stdio: "inherit" }); - return new Response("Migrated"); -} diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 273e0d0..394e916 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation"; import BlogContent from "./components/BlogContent"; import SubscriberForm from "../components/SubscriberForm"; +import { getBlog } from "@/actions/blog/getBlog"; interface BlogParams { params: { @@ -9,25 +10,15 @@ interface BlogParams { } export default async function BlogPost({ params }: BlogParams) { - const blog = await fetch( - `${process.env.NEXT_PUBLIC_URL}/api/blog/${params.slug}`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - } - ); - - const blogData = await blog.json(); + const blog = await getBlog(params.slug); - if (!blogData || !blogData.data) { + if (!blog) { return notFound(); } return ( <> - +
diff --git a/app/experience/page.tsx b/app/experience/page.tsx index 9887b1b..67af8de 100644 --- a/app/experience/page.tsx +++ b/app/experience/page.tsx @@ -6,11 +6,11 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import { workExperience } from "@/data/workExperience"; import { Home } from "lucide-react"; +import { getWorkExperience } from "@/actions/work/getWorkExperience"; -export default function ExperiencePage() { - const experiences = workExperience; +export default async function ExperiencePage() { + const experiences = await getWorkExperience(); const formatDate = (date: string): string => { if (date === "Current") return "Present"; return new Date(date).toLocaleDateString("en-US", { diff --git a/app/page.tsx b/app/page.tsx index 3a869d7..f80f246 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,8 +3,6 @@ import Image from "next/image"; import Me from "@/public/me.png"; import { TimelineList } from "@/components/TimelineList"; import { AnimatedDescription } from "@/components/AnimatedDescription"; -import BlogList from "@/components/BlogList"; -import { Card } from "@/components/ui/card"; import BlogCard from "@/components/BlogCard"; export default async function Index() { const descriptions = [ diff --git a/components/BlogList.tsx b/components/BlogList.tsx index 2cd013e..fe6bde5 100644 --- a/components/BlogList.tsx +++ b/components/BlogList.tsx @@ -2,20 +2,15 @@ import { IBlogList } from "@/lib/types"; import BlogCard from "./BlogCard"; import { Suspense } from "react"; import BlogListSkeleton from "./BlogListSkeleton"; +import { getBlogs } from "@/actions/blog/getBlogs"; export default async function BlogList() { - const data = await fetch(`${process.env.NEXT_PUBLIC_URL}/api/blog`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - const blogs = await data.json(); + const blogs = await getBlogs(); return ( }>
- {blogs.data?.map((blog: IBlogList, index: number) => ( + {blogs?.map((blog: IBlogList, index: number) => ( { +export const TimelineList: React.FC = async () => { + const experiences = await getWorkExperience(); const formatDate = (date: string): string => { if (date === "Current") return "Current"; return new Date(date).getFullYear().toString(); }; - const timelineData: TimelineItem[] = workExperience.map((exp) => ({ + const timelineData: TimelineItem[] = experiences.map((exp) => ({ company: exp.company, startDate: exp.startDate, endDate: exp.endDate, diff --git a/data/workExperience.ts b/data/workExperience.ts deleted file mode 100644 index cfa31c0..0000000 --- a/data/workExperience.ts +++ /dev/null @@ -1,39 +0,0 @@ -export const workExperience = [ - { - id: 1, - company: "Clayton Homes", - position: "Software Engineer", - startDate: "2023-12-18", - endDate: "Current", - content: `At Clayton Homes, I lead the redevelopment of the VMFHomes website, a public-facing platform for purchasing used and reposed manufactured homes. The project is built in Next.js with TypeScript and utilizes Contentful as the CMS for dynamic content management. I've implemented Open-Next to bundle the application and Terraform for Infrastructure as Code (IaC) to deploy on AWS, creating a fully serverless, scalable solution that reduces hosting costs. Additionally, the site integrates with a .NET Core backend API to retrieve asset information, ensuring accurate, up-to-date listings for users. - -I also developed a custom GitHub Actions CI/CD pipeline, reducing deployment time by 84% and enabling faster, more reliable updates. This project has uplifted the previous site, modernizing the technology stack and enhancing user experience while optimizing operational costs.`, - }, - { - id: 2, - company: "Datably, Inc", - position: "Software Engineer", - startDate: "2022-04-01", - endDate: "2023-11-03", - content: - "At Datably, I focused on creating scalable, well-architected software solutions using Test-Driven Development (TDD) and Domain-Driven Design (DDD) principles. My work emphasized maintaining clean, modular code adhering to the Single Responsibility Principle, ensuring that each class served a specific purpose. I worked closely with cross-functional team members to help define project scopes, design robust features, and groom the backlog for maintainable sprint flow. Major projects I've contributed to include Ignition (a React-based platform), RTTN Website and Mobile App (HTML, LESS, JavaScript, and React Native), and Crash Companion (React Native). These projects range from modernizing legacy systems to building brand-new applications from the ground up.", - }, - { - id: 3, - company: "Cracker Barrel", - position: "Full Stack Developer", - startDate: "2021-08-01", - endDate: "2022-04-01", - content: - "In my role at Cracker Barrel, I was responsible for optimizing both web and mobile applications, enhancing performance, and reducing load times. Leveraging technologies like React.js, Axios, and Redux, I worked on Azure for cloud scaling and deployment. I managed load balancing with Azure Front Door and facilitated streamlined deployment processes with Azure DevOps. My role involved orchestrating branching strategies for smooth deployments and maintaining strong partnerships with third-party vendors, ensuring reliable service integrations.", - }, - { - id: 4, - company: "McKee Foods", - position: "Software Engineer Intern", - startDate: "2020-01-01", - endDate: "2021-08-01", - content: - "As an intern, I gained hands-on experience in full-stack development, working with React, TypeScript, ASP.NET APIs, and PL/SQL databases. I contributed to the MyBusiness app for U.S. based independent distributors, maintaining its admin site and enhancing usability. My work included migrating the Maximo system to a React-based platform, optimizing workflows in manufacturing, and gathering valuable software usage metrics. These efforts supported data-driven decisions and improved operational efficiency across teams.", - }, -]; diff --git a/emails/index.tsx b/emails/index.tsx index 3fa8f06..afbd2f6 100644 --- a/emails/index.tsx +++ b/emails/index.tsx @@ -19,7 +19,7 @@ import config from "../tailwind.config"; import Subscribed from "./subscribed"; import Unsubscribed from "./unsubscribed"; export const url = (route: string): string => { - let base = process.env.URL ?? ""; + let base = process.env.NEXT_PUBLIC_URL ?? ""; return `${base}${route}`; }; diff --git a/emails/subscribed.tsx b/emails/subscribed.tsx index 9301a7a..80f75aa 100644 --- a/emails/subscribed.tsx +++ b/emails/subscribed.tsx @@ -1,18 +1,6 @@ -import { - Body, - Button, - Container, - Head, - Hr, - Html, - Img, - Link, - Preview, - Section, - Text, -} from "@react-email/components"; +import { Text } from "@react-email/components"; import * as React from "react"; -import { EmailWrapper, url } from "."; +import { EmailWrapper } from "."; const Subscribed: React.FC = () => { return ( diff --git a/emails/unsubscribed.tsx b/emails/unsubscribed.tsx index 808a109..db092b5 100644 --- a/emails/unsubscribed.tsx +++ b/emails/unsubscribed.tsx @@ -1,18 +1,6 @@ -import { - Body, - Button, - Container, - Head, - Hr, - Html, - Img, - Link, - Preview, - Section, - Text, -} from "@react-email/components"; +import { Text } from "@react-email/components"; import * as React from "react"; -import { EmailWrapper, url } from "."; +import { EmailWrapper } from "."; const Unsubscribed: React.FC = () => { return ( diff --git a/lib/schema.ts b/lib/schema.ts index 7521f61..165b4d6 100644 --- a/lib/schema.ts +++ b/lib/schema.ts @@ -1,4 +1,12 @@ -import { pgTable, serial, uuid, text, timestamp } from "drizzle-orm/pg-core"; +import { + pgTable, + serial, + uuid, + text, + timestamp, + varchar, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; export const blogsTable = pgTable("blogs_table", { id: serial("id").primaryKey().notNull(), @@ -20,5 +28,22 @@ export const subscribersTable = pgTable("subscribers_table", { deletedAt: timestamp("deleted_at"), }); +export const workExperienceTable = pgTable("work_experience", { + id: serial("id").primaryKey(), + company: varchar("company", { length: 255 }).notNull(), + position: varchar("position", { length: 255 }).notNull(), + startDate: varchar("start_date", { length: 255 }).notNull(), + endDate: varchar("end_date", { length: 255 }).notNull(), + content: text("content").notNull(), + createdAt: timestamp("created_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updated_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), +}); + export type SelectBlog = typeof blogsTable.$inferSelect; export type InsertSubscriber = typeof subscribersTable.$inferInsert; +export type WorkExperience = typeof workExperienceTable.$inferSelect; +export type NewWorkExperience = typeof workExperienceTable.$inferInsert; diff --git a/lib/secrets.ts b/lib/secrets.ts deleted file mode 100644 index 82ee9b0..0000000 --- a/lib/secrets.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm"; - -//Grab Secrets from AWS Parameter Store -const STAGE = "production"; -const PROJECT = "letusdev"; -const REGION = "us-east-1"; - -export async function getSecret(secretName: string) { - if (!secretName) { - throw new Error("Secret name is required"); - } - - const client = new SSMClient({ region: REGION }); - const paramName = `/sst/${PROJECT}/${STAGE}/Secret/${secretName}/value`; - - const paramOptions = { - Name: paramName, - WithDecryption: true, - }; - - const command = new GetParameterCommand(paramOptions); - const response = await client.send(command); - - return response; -} diff --git a/package.json b/package.json index e4a3ee6..b65964b 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "next dev", "start": "next start", "deploy": "sst deploy --stage production", + "deploy:staging": "sst deploy --stage staging", "takeDown": "npx sst remove --stage production", "generate": "npx drizzle-kit generate", "db": "sst shell drizzle-kit" diff --git a/sst-env.d.ts b/sst-env.d.ts index 8dc9a65..a4f7924 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -10,11 +10,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "MyEmail": { - "configSet": string - "sender": string - "type": "sst.aws.Email" - } "NEXT_PUBLIC_URL": { "type": "sst.sst.Secret" "value": string diff --git a/sst.config.ts b/sst.config.ts index 400c4f5..d688184 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -10,9 +10,6 @@ export default $config({ }; }, async run() { - const email = new sst.aws.Email("MyEmail", { - sender: "lucas@strukt.io", - }); const database_url = new sst.Secret("DATABASE_URL"); const next_public_url = new sst.Secret("NEXT_PUBLIC_URL"); new sst.aws.Nextjs("letusdev", { @@ -21,7 +18,10 @@ export default $config({ NEXT_PUBLIC_URL: next_public_url.value, }, domain: { - name: "letusdev.io", + name: + $app.stage === "production" + ? "letusdev.io" + : `${$app.stage}.letusdev.io`, dns: sst.aws.dns({ zone: "Z0600725332UFN0OF4ISC", }),