initial commit: setup astro + vue + supabase

This commit is contained in:
Yoga Pangestu 2026-07-02 14:58:07 +07:00
commit 3c649ab739
86 changed files with 19971 additions and 0 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
# Supabase Configuration
# Replace these with your actual credentials from the Supabase Dashboard
SUPABASE_URL=https://your-project-id.supabase.co
SUPABASE_ANON_KEY=your-anon-key

31
.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# Build & Output
dist/
.vercel/
.astro/
# Dependencies
node_modules/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment Variables & Secrets
.env
.env.production
.env.local
.env.development
.env.*.local
# IDE & Editors
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.DS_Store
Thumbs.db

53
README.md Normal file
View File

@ -0,0 +1,53 @@
# Yoga Pangestu - Portfolio Website
![Screenshot](./public/screenshot-dark.png)
![Screenshot](./public/screenshot-light.png)
## Overview
Welcome to my personal portfolio website! This project is a modern, responsive, and performance-focused web application built with **Astro**. It serves as a digital showcase for my projects, skills, and professional journey as a Full Stack Developer.
## About Me
I am a **Full Stack Developer** with over 3 years of experience in full-stack web application development. I specialize in building scalable, high-performance, and secure digital solutions, covering all stages from system design to deployment.
> "Coding, Sleep, Repeat..."
## Features
- ⚡ **High Performance** Built with **Astro** for blazing fast load times.
- 🎨 **Modern UI/UX** Styled with **TailwindCSS** and **Framer Motion** for smooth animations.
- <20> **Fully Responsive** Optimized for all devices, from mobile to desktop.
- <20> **Dark Mode Support** Seamless theme switching.
- <20> **Project Showcase** Detailed case studies of my work, including:
- **Webdesaku**
- **CSR Purwakarta**
- **Sibadeksa**
- **Yadi Parfum**
- and more...
- 📖 **Blog & Insights** Sharing knowledge and updates on web technologies.
## Tech Stack
This portfolio is built using a modern stack:
- **Astro** Static Site Generation (SSG) for performance.
- **React** Component-based UI architecture.
- **TailwindCSS** Utility-first CSS framework.
- **TypeScript** Type-safe code for better maintainability.
- **Framer Motion** For interactive animations.
- **MDX** For writing content-rich blog posts and project details.
## Project Structure
- `src/data/projects` MDX files for project descriptions.
- `src/lib/constants` Configuration for profile info, tech stack, and site metadata.
- `src/pages` Application routes and views.
- `public` Static assets.
## Contact
Feel free to reach out for collaborations or inquiries!
- **Email**: info.pangestuyoga@gmail.com

18
astro.config.mjs Normal file
View File

@ -0,0 +1,18 @@
// @ts-check
import { defineConfig } from "astro/config";
import tailwindcss from "@tailwindcss/vite";
import vue from "@astrojs/vue";
import mdx from "@astrojs/mdx";
import icon from "astro-icon";
import vercel from "@astrojs/vercel";
// https://astro.build/config
export default defineConfig({
site: 'https://pangestu.vercel.app',
adapter: vercel(),
integrations: [vue(), mdx(), icon()],
vite: {
plugins: [tailwindcss()],
},
});

19
eslint.config.js Normal file
View File

@ -0,0 +1,19 @@
import eslintPluginAstro from "eslint-plugin-astro";
import globals from "globals";
import tseslint from "typescript-eslint";
export default [
...tseslint.configs.recommended,
...eslintPluginAstro.configs.recommended,
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{ rules: { "no-console": "error" } },
{ ignores: ["dist/**", ".astro", "public/pagefind/**"] },
];

11619
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "pangestu-portfolio",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^4.2.1",
"@astrojs/vercel": "^9.0.5",
"@astrojs/vue": "^5.1.4",
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/jetbrains-mono": "^5.2.5",
"@fontsource/roboto-condensed": "^5.2.5",
"@supabase/supabase-js": "^2.110.0",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.0.16",
"@typescript-eslint/parser": "^8.28.0",
"@vercel/analytics": "^1.5.0",
"astro": "^5.5.4",
"astro-icon": "^1.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"developer-icons": "^5.3.2",
"eslint": "^9.23.0",
"eslint-plugin-astro": "^1.3.1",
"framer-motion": "^12.6.0",
"install": "^0.13.0",
"lucide-react": "^0.484.0",
"markdown-it": "^14.1.0",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-to-string": "^4.0.0",
"prettier": "^3.5.3",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.11",
"react-markdown": "^10.1.0",
"reading-time": "^1.5.0",
"sharp": "^0.33.5",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.16",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.8.2",
"typescript-eslint": "^8.28.0",
"vue": "^3.5.39"
},
"devDependencies": {
"@iconify-json/devicon": "^1.2.62",
"@iconify-json/fluent-color": "^1.2.21",
"@iconify-json/logos": "^1.2.11",
"@iconify-json/lucide": "^1.2.112",
"@iconify-json/simple-icons": "^1.2.86",
"@iconify-json/skill-icons": "^1.2.4"
}
}

5527
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

9
public/favicon.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

BIN
public/screenshot-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

BIN
public/screenshot-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

BIN
src/assets/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 KiB

View File

@ -0,0 +1,63 @@
---
import { ChevronUp } from "lucide-react";
import { buttonVariants, cn } from "@/lib/utils";
---
<script>
document.addEventListener("astro:page-load", () => {
function handleScroll() {
const button = document.querySelector("#back-to-top");
if (!button) return;
if (window.scrollY < window.innerHeight) {
button.classList.add("scale-0");
} else {
button.classList.remove("scale-0");
}
}
function backToTop() {
const button = document.querySelector("#back-to-top");
if (!button) return;
button.addEventListener("click", () => {
document.body.scrollTop = 0; // For Safari
document.documentElement.scrollTop = 0; // For Chrome, Firefox, IE, and Opera
});
// Check scroll position on page load and scroll events
handleScroll();
window.addEventListener("scroll", handleScroll);
}
// Initialize the back to top functionality
backToTop();
});
/* Go to page start after page swap */
document.addEventListener("astro:after-swap", () => {
window.scrollTo({ left: 0, top: 0, behavior: "instant" });
// Need to wait a moment for the button to be available in the DOM after swap
setTimeout(() => {
const button = document.querySelector("#back-to-top");
if (!button) return;
if (window.scrollY < window.innerHeight) {
button.classList.add("scale-0");
} else {
button.classList.remove("scale-0");
}
}, 10);
});
</script>
<button
id="back-to-top"
aria-label="back-to-top-button"
title="Back to Top Button"
class={cn(
buttonVariants({ size: "icon" }),
"fixed right-4 bottom-[4rem] scale-0 z-50 cursor-pointer duration-150 transition-transform rounded-full lg:left-[calc(100vw/2+400px)]"
)}
>
<ChevronUp />
</button>

View File

@ -0,0 +1,11 @@
---
interface Props {
className?: string;
}
const { className } = Astro.props;
---
<div class:list={["p-4 text-sm", className]}>
<slot />
</div>

View File

@ -0,0 +1,11 @@
---
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<div class:list={["relative px-4 py-2", className]}>
<slot />
</div>

View File

@ -0,0 +1,11 @@
---
interface Props {
className?: string;
}
const { className } = Astro.props;
---
<section class:list={["md:border-x", className]}>
<slot />
</section>

View File

@ -0,0 +1,11 @@
---
interface Props {
className?: string;
}
const { className } = Astro.props;
---
<h2 class:list={["text-2xl font-semibold", className]}>
<slot />
</h2>

View File

@ -0,0 +1,13 @@
<footer class="w-full border-t border-dashed md:border-b">
<div
class="mx-auto flex max-w-3xl flex-col-reverse items-center justify-between gap-4 border-dashed px-4 py-3 md:border-x lg:flex-row"
>
<p class="text-sm text-foreground/70">
Created by <a
href="https://github.com/pangestuyoga"
class="font-medium text-primary underline underline-offset-4"
>Yoga Pangestu</a
>
</p>
</div>
</footer>

View File

@ -0,0 +1,61 @@
---
import { Moon, Sun } from "lucide-react";
import { cn, buttonVariants } from "@/lib/utils";
---
<button
id="theme-toggle"
class={cn(
buttonVariants({ size: "icon", variant: "ghost" }),
"cursor-pointer rounded-full"
)}
>
<Sun
id="sun-icon"
className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90"
/>
<Moon
id="moon-icon"
className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0"
/>
<span class="sr-only">Toggle theme</span>
</button>
<script is:inline>
// Check for theme preference in localStorage or system preference
const getThemePreference = () => {
if (typeof localStorage !== "undefined" && localStorage.getItem("theme")) {
return localStorage.getItem("theme");
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
};
// Set theme on document and in localStorage
const setThemeAppearance = theme => {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
localStorage.setItem("theme", theme);
};
// Apply the theme immediately to avoid flash
const theme = getThemePreference();
setThemeAppearance(theme);
document.addEventListener("astro:page-load", () => {
const toggleButton = document.getElementById("theme-toggle");
// Toggle theme function
const toggleMode = () => {
const isDarkMode = document.documentElement.classList.contains("dark");
const newTheme = isDarkMode ? "light" : "dark";
setThemeAppearance(newTheme);
};
// Add click event listener
toggleButton?.addEventListener("click", toggleMode);
});
</script>

View File

@ -0,0 +1,31 @@
---
import ModeToggle from "@/components/mode-toggle.astro";
import NavLinks from "./nav-links.astro";
import { PROFILE_INFO } from "@/lib/constants/profile";
interface Props {
className?: string;
}
const { className } = Astro.props;
---
<nav
class:list={[
"screen-line-after sticky top-0 z-50 w-full bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60",
className,
]}
>
<div
class="mx-auto flex max-w-3xl items-center justify-between px-4 py-1 md:border-x"
>
<a href="/">
<h1 class="text-xl font-bold">{PROFILE_INFO.logo}</h1>
</a>
<div class="flex items-center justify-center gap-4">
<NavLinks />
<ModeToggle />
</div>
</div>
</nav>

View File

@ -0,0 +1,42 @@
---
import { NAV_LINKS } from "@/lib/constants/index";
import { cn } from "@/lib/utils";
const pathname = Astro.url.pathname;
// Remove trailing slash from current pathname if exists
const currentPath =
pathname.endsWith("/") && pathname !== "/" ? pathname.slice(0, -1) : pathname;
const isActive = (path: string) => {
const currentPathArray = currentPath.split("/").filter(p => p.trim());
const pathArray = path.split("/").filter(p => p.trim());
return currentPath === path || currentPathArray[0] === pathArray[0];
};
---
<div class="flex items-center justify-center gap-4">
{
NAV_LINKS.map(link => (
<a
href={link.href}
class={cn(
"group relative flex flex-col items-center justify-center text-xs font-medium text-foreground/70 duration-150 hover:text-primary lg:text-sm"
)}
>
<span class={cn("mb", isActive(link.href) && "text-primary")}>
{link.label}
</span>
<span
class={cn(
"absolute -bottom-px h-[1px] rounded-full bg-primary transition-all duration-150",
isActive(link.href) ? "w-full" : "w-0",
"group-hover:w-full"
)}
/>
</a>
))
}
</div>

View File

@ -0,0 +1,40 @@
---
import type { Page } from "astro";
import type { CollectionEntry } from "astro:content";
import LinkButton from "@/components/ui/link-btn.astro";
import { ArrowLeft, ArrowRight } from "lucide-react";
export interface Props {
page: Page<CollectionEntry<"blog">>;
}
const { page } = Astro.props;
---
{
page.lastPage > 1 && (
<nav
class="flex items-center justify-center gap-3 text-sm"
aria-label="Pagination"
>
<LinkButton
disabled={!page.url.prev}
href={page.url.prev as string}
ariaLabel="Previous"
>
<ArrowLeft className="inline-block" />
Previous
</LinkButton>
<span class="font-bold">{page.currentPage} </span>/ {page.lastPage}
<LinkButton
disabled={!page.url.next}
href={page.url.next as string}
ariaLabel="Next"
>
Next
<ArrowRight className="inline-block" />
</LinkButton>
</nav>
)
}

View File

@ -0,0 +1,141 @@
---
import BoxContent from "@/components/box/content.astro";
import Box from "@/components/box/index.astro";
import Avatar from "@/components/ui/avatar.astro";
import { PROFILE_INFO } from "@/lib/constants/profile";
import { Icon } from "astro-icon/components";
---
<div class="screen-line-after relative flex items-center p-4 md:border-x">
<div class="shrink-0">
<div class="relative z-1 mx-0.5 my-1">
<Avatar
className="size-20 rounded-full ring-1 ring-border ring-offset-2 ring-offset-background sm:size-28"
/>
</div>
</div>
<div class="flex flex-1 flex-col">
<div class="flex grow items-end pb-1 pl-4">
<div class="line-clamp-1 font-mono text-xs text-muted-foreground">
{PROFILE_INFO.slogan}
</div>
</div>
<div class="">
<h1 class="flex items-center gap-x-2 pl-4 text-2xl font-black">
{PROFILE_INFO.displayName}
<Icon name="lucide:badge-check" class="size-4 text-blue-500" />
</h1>
<div class="py-1 pl-4">
<p class="text-sm !font-normal">
{PROFILE_INFO.role}
</p>
</div>
</div>
</div>
</div>
{
(
<div class="screen-line-after inline-flex w-full items-center gap-2 border-x bg-yellow-100 px-3 py-2 text-sm font-semibold dark:bg-yellow-500/10">
<span class="animate-bounce text-base">👋</span> Let's build something
great together —
<a
href={`mailto:${PROFILE_INFO.email}`}
class="underline underline-offset-4"
>
let's talk
</a>
</div>
)
}
<Box>
<BoxContent className="space-y-3">
<span class="flex items-center justify-start gap-2">
<Icon name="lucide:briefcase" class="size-4" />
I build software at
<a
href="https://pratamatechsolution.co.id"
target="_blank"
class="underline underline-offset-4"
>
PST
</a>
</span>
<span class="flex items-center justify-start gap-2">
<Icon name="lucide:file-text" class="size-4" />
Take a look at my
<a
href="https://drive.google.com/drive/folders/1AzP0gexDJrz9_T42TZkCMPn1MJCEvfP4?usp=sharing"
target="_blank"
class="underline underline-offset-4"
>
Resume
</a>
</span>
<span class="flex items-center justify-start gap-2">
<Icon name="skill-icons:instagram" class="size-4" />
Follow me on
<a
href="https://www.instagram.com/ygapangestuuu_/"
target="_blank"
class="underline underline-offset-4"
>
Instagram
</a>
</span>
<span class="flex items-center justify-start gap-2">
<Icon name="logos:threads-icon" class="size-4" />
Follow me on
<a
href="https://www.threads.com/@ygapangestuuu_"
target="_blank"
class="underline underline-offset-4"
>
Threads
</a>
</span>
<span class="flex items-center justify-start gap-2">
<Icon name="logos:facebook" class="size-4" />
Follow me on
<a
href="https://www.facebook.com/profile.php?id=100092236272919"
target="_blank"
class="underline underline-offset-4"
>
Facebook
</a>
</span>
<span class="flex items-center justify-start gap-2">
<Icon name="skill-icons:linkedin" class="size-4" />
Let's connect on
<a
href="https://www.linkedin.com/in/pangestuu/"
class="underline underline-offset-4"
target="_blank"
>
LinkedIn
</a>
</span>
<span class="flex items-center justify-start gap-2">
<Icon name="devicon:github" class="size-4" />
Check out my repos on
<a
href="https://github.com/pangestuyoga"
class="underline underline-offset-4"
target="_blank"
>
GitHub
</a>
</span>
</BoxContent>
</Box>

View File

@ -0,0 +1,21 @@
---
import { PROFILE_INFO } from "@/lib/constants/profile";
import MarkdownRenderer from "@/components/ui/markdown-renderer.astro";
import Box from "@/components/box/index.astro";
import BoxHeader from "@/components/box/header.astro";
import BoxTitle from "@/components/box/title.astro";
import BoxContent from "@/components/box/content.astro";
---
<Box>
<BoxHeader>
<BoxTitle> About </BoxTitle>
</BoxHeader>
<BoxContent>
<MarkdownRenderer
className="text-sm leading-7 font-medium"
content={PROFILE_INFO.about}
/>
</BoxContent>
</Box>

View File

@ -0,0 +1,30 @@
---
import type { Experience } from "@/lib/types";
import ExperiencePositionItem from "./position-item.astro";
interface Props {
experience: Experience;
}
const { experience } = Astro.props;
---
<div class="space-y-4 p-4 not-last:border-b">
<div class="flex items-center space-x-4 px-2">
<span class="size-2 rounded-full bg-foreground/60"></span>
<h3 class="text-xl font-black">
{experience.company}
</h3>
</div>
<div
class="relative space-y-4 pl-1 before:absolute before:left-3 before:h-full before:w-px before:bg-border"
>
{
experience.positions.map((position, index) => {
return <ExperiencePositionItem position={position} />;
})
}
</div>
</div>

View File

@ -0,0 +1,22 @@
---
import Box from "@/components/box/index.astro";
import BoxHeader from "@/components/box/header.astro";
import BoxTitle from "@/components/box/title.astro";
import BoxContent from "@/components/box/content.astro";
import { EXPERIENCES } from "@/lib/constants/experience";
import ExperienceItem from "./experience-item.astro";
---
<Box>
<BoxHeader>
<BoxTitle> Experience </BoxTitle>
</BoxHeader>
<BoxContent className="px-0">
{
EXPERIENCES.map(experience => {
return <ExperienceItem experience={experience} />;
})
}
</BoxContent>
</Box>

View File

@ -0,0 +1,118 @@
---
import { Code2, ChevronDown, ChevronUp } from "lucide-react";
import type { ExperiencePosition } from "@/lib/types";
import Badge from "@/components/ui/badge.astro";
interface Props {
position: ExperiencePosition;
}
const { position } = Astro.props;
---
<div>
<div class="block w-full text-left">
<div class="relative z-1 mb-1 flex items-center space-x-3">
<Code2 className="size-4 bg-background" />
<h3 class="text-base font-black underline-offset-4">
{position.title}
</h3>
</div>
<span
class="flex items-center gap-2 pl-7 font-mono text-sm text-foreground"
>
{position.year}
</span>
</div>
<div class="description-container my-4 overflow-hidden">
<div
class="description-content max-h-0 overflow-hidden transition-all duration-300"
>
<ul class="ml-7 list-none space-y-2 text-sm leading-7">
{
position.description.map((item) => (
<li class="description-item text-justify">{item}</li>
))
}
</ul>
</div>
<button
class="toggle-btn mt-2 ml-7 flex cursor-pointer items-center gap-1 rounded-md border px-4 py-2 text-xs font-medium text-primary hover:underline focus:outline-none"
>
<span class="toggle-text">Show more</span>
<ChevronDown className="toggle-icon-down size-4" />
<ChevronUp className="toggle-icon-up hidden size-4" />
</button>
</div>
</div>
<style>
.description-content.expanded {
max-height: fit-content;
}
.description-container .toggle-icon-up.visible {
display: block;
}
.description-container .toggle-icon-down.hidden {
display: none;
}
.description-item {
position: relative;
padding-left: 1.5rem;
}
.description-item::before {
content: "♦";
position: absolute;
left: 0;
top: 0;
}
</style>
<script>
document.addEventListener("astro:page-load", () => {
const toggleButtons = document.querySelectorAll(".toggle-btn");
toggleButtons.forEach((button, index) => {
const container = button.closest(".description-container");
if (!container) return;
const content = container.querySelector(".description-content");
const toggleText = button.querySelector(".toggle-text");
const iconDown = button.querySelector(".toggle-icon-down");
const iconUp = button.querySelector(".toggle-icon-up");
if (index === 0) {
if (content) content.classList.add("expanded");
if (toggleText) toggleText.textContent = "Show less";
if (iconDown) iconDown.classList.add("hidden");
if (iconUp) iconUp.classList.remove("hidden");
iconUp?.classList.add("visible");
}
button.addEventListener("click", () => {
if (content) {
content.classList.toggle("expanded");
if (content.classList.contains("expanded")) {
if (toggleText) toggleText.textContent = "Show less";
if (iconDown) iconDown.classList.add("hidden");
if (iconUp) iconUp.classList.remove("hidden");
iconUp?.classList.add("visible");
} else {
if (toggleText) toggleText.textContent = "Show more";
if (iconDown) iconDown.classList.remove("hidden");
if (iconUp) iconUp.classList.add("hidden");
iconUp?.classList.remove("visible");
}
}
});
});
});
</script>

View File

@ -0,0 +1,49 @@
---
import { ChevronRightIcon, FolderCodeIcon } from "lucide-react";
import MarkdownRenderer from "@/components/ui/markdown-renderer.astro";
import type { CollectionEntry } from "astro:content";
import Badge from "@/components/ui/badge.astro";
interface Props {
project: CollectionEntry<"projects">;
}
const { project } = Astro.props;
---
<a
href={`/projects/${project.id}`}
class="flex cursor-pointer items-center not-last:border-b"
>
<FolderCodeIcon className="mx-4.5 size-5 shrink-0 text-muted-foreground" />
<div class="group flex w-full cursor-pointer flex-col gap-y-3 border-l p-4">
<div class="flex w-full items-center justify-between text-left select-none">
<h3
class="text-lg font-semibold underline-offset-6 group-hover:underline"
>
{project.data.name}
</h3>
<ChevronRightIcon
className="size-4 shrink-0 text-muted-foreground transition-transform duration-150 group-hover:translate-x-1"
/>
</div>
<MarkdownRenderer
className="text-xs leading-5 font-medium md:text-sm"
content={project.data.description}
/>
{
project.data.technologies.length > 0 && (
<div class="flex flex-wrap gap-1.5">
{project.data.technologies.map(technology => {
return <Badge>{technology}</Badge>;
})}
</div>
)
}
</div>
</a>

View File

@ -0,0 +1,41 @@
---
import Box from "@/components/box/index.astro";
import BoxHeader from "@/components/box/header.astro";
import BoxTitle from "@/components/box/title.astro";
import BoxContent from "@/components/box/content.astro";
import { TECH_STACK } from "@/lib/constants/tech-stack";
import { Icon } from "astro-icon/components";
---
<Box>
<BoxHeader>
<BoxTitle>Tech Stack</BoxTitle>
</BoxHeader>
<BoxContent>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3"
style="content-visibility: auto; contain-intrinsic-size: auto 48px;"
>
{
TECH_STACK.map(item => (
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
class:list={[
"flex h-10 min-w-[120px] items-center justify-center gap-2 rounded-md border border-solid bg-blue-50/10 p-2",
"transition-all duration-200 hover:bg-blue-50/20",
]}
>
{item.icon && (
<span class="flex size-5 items-center justify-center">
<Icon name={item.icon} class="size-5" />
</span>
)}
<span class="truncate text-sm">{item.title}</span>
</a>
))
}
</div>
</BoxContent>
</Box>

29
src/components/toc.astro Normal file
View File

@ -0,0 +1,29 @@
---
import type { MarkdownHeading } from "astro";
interface Props {
headings: MarkdownHeading[];
}
const { headings } = Astro.props;
---
<div
class="sticky top-18 mt-4 ml-4 hidden max-h-fit min-w-fit flex-col gap-y-3 lg:flex"
>
<h3 class:list="text-xl">On This Page</h3>
{
headings
.filter(({ depth }) => depth < 3)
.map(({ slug, text }) => (
<a
href={`#${slug}`}
class:list={[
"text-sm font-normal text-foreground/70 no-underline duration-75 hover:text-foreground",
]}
>
• {text}
</a>
))
}
</div>

View File

@ -0,0 +1,32 @@
---
import { Picture } from "astro:assets";
import ImgAvatar from "@/assets/avatar.png";
import { PROFILE_INFO } from "@/lib/constants/profile";
interface Props {
className?: string;
size?: number;
priority?: boolean;
}
const {
className,
size = 100, // Default size if not provided
priority = false,
} = Astro.props;
// Determine image formats for fallback and webp
const formats = ["avif", "webp"];
---
<Picture
class:list={[className]}
src={ImgAvatar}
widths={[size, size * 2]}
sizes={`(max-width: ${size}px) 100vw, ${size}px`}
formats={formats}
alt={`${PROFILE_INFO.displayName}'s avatar`}
quality="mid"
loading={priority ? "eager" : "lazy"}
decoding="async"
/>

View File

@ -0,0 +1,5 @@
<span
class="inline-flex items-center rounded-md border !border-solid px-2 py-0.5 text-xs"
>
<slot />
</span>

View File

@ -0,0 +1,17 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...Astro.props}
>
<path
fill="currentColor"
d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 2.4c5.302 0 9.6 4.298 9.6 9.6S17.302 21.6 12 21.6 2.4 17.302 2.4 12 6.698 2.4 12 2.4z"
></path>
<path
fill="currentColor"
d="M8.4 6h7.2l-3.6 10.8L8.4 6zm1.2 2.4l2.4 7.2 2.4-7.2H9.6z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 452 B

View File

@ -0,0 +1,21 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...Astro.props}
>
<path
fill="currentColor"
d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 2.4c5.302 0 9.6 4.298 9.6 9.6S17.302 21.6 12 21.6 2.4 17.302 2.4 12 6.698 2.4 12 2.4z"
></path>
<path
fill="currentColor"
d="M7.2 6h9.6v12H7.2V6zm1.2 1.2v9.6h7.2V7.2H8.4z"
></path>
<path
fill="currentColor"
d="M9.6 9.6h4.8v4.8H9.6V9.6zm1.2 1.2v2.4h2.4v-2.4h-2.4z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 545 B

View File

@ -0,0 +1,13 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...Astro.props}
>
<path
fill="currentColor"
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 471 B

View File

@ -0,0 +1,21 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...Astro.props}
>
<path
fill="currentColor"
d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 2.4c5.302 0 9.6 4.298 9.6 9.6S17.302 21.6 12 21.6 2.4 17.302 2.4 12 6.698 2.4 12 2.4z"
></path>
<path
fill="currentColor"
d="M12 6l-6 6 6 6 6-6-6-6zm0 2.4L16.8 12 12 15.6 7.2 12 12 8.4z"
></path>
<path
fill="currentColor"
d="M10.8 10.8h2.4v2.4h-2.4v-2.4z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 537 B

View File

@ -0,0 +1,21 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...Astro.props}
>
<path
fill="currentColor"
d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 2.4c5.302 0 9.6 4.298 9.6 9.6S17.302 21.6 12 21.6 2.4 17.302 2.4 12 6.698 2.4 12 2.4z"
></path>
<path
fill="currentColor"
d="M6 8h12v8H6V8zm1.2 1.2v5.6h9.6V9.2H7.2z"
></path>
<path
fill="currentColor"
d="M9.6 12h4.8v1.2H9.6V12z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 510 B

View File

@ -0,0 +1,6 @@
<svg viewBox="0 0 438.549 438.549" {...Astro.props}>
<path
fill="currentColor"
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,13 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...Astro.props}
>
<path
fill="currentColor"
d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,13 @@
<svg
width="800px"
height="800px"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...Astro.props}
>
<path
fill="currentColor"
d="M22 3.47059V20.5294C22 20.9194 21.8451 21.2935 21.5693 21.5693C21.2935 21.8451 20.9194 22 20.5294 22H3.47059C3.08056 22 2.70651 21.8451 2.43073 21.5693C2.15494 21.2935 2 20.9194 2 20.5294V3.47059C2 3.08056 2.15494 2.70651 2.43073 2.43073C2.70651 2.15494 3.08056 2 3.47059 2H20.5294C20.9194 2 21.2935 2.15494 21.5693 2.43073C21.8451 2.70651 22 3.08056 22 3.47059ZM7.88235 9.64706H4.94118V19.0588H7.88235V9.64706ZM8.14706 6.41177C8.14861 6.18929 8.10632 5.96869 8.02261 5.76255C7.93891 5.55642 7.81542 5.36879 7.65919 5.21039C7.50297 5.05198 7.31708 4.92589 7.11213 4.83933C6.90718 4.75277 6.68718 4.70742 6.46471 4.70588H6.41177C5.95934 4.70588 5.52544 4.88561 5.20552 5.20552C4.88561 5.52544 4.70588 5.95934 4.70588 6.41177C4.70588 6.86419 4.88561 7.29809 5.20552 7.61801C5.52544 7.93792 5.95934 8.11765 6.41177 8.11765C6.63426 8.12312 6.85565 8.0847 7.06328 8.00458C7.27092 7.92447 7.46074 7.80422 7.62189 7.65072C7.78304 7.49722 7.91237 7.31346 8.00248 7.10996C8.09259 6.90646 8.14172 6.6872 8.14706 6.46471V6.41177ZM19.0588 13.3412C19.0588 10.5118 17.2588 9.41177 15.4706 9.41177C14.8851 9.38245 14.3021 9.50715 13.7799 9.77345C13.2576 10.0397 12.8143 10.4383 12.4941 10.9294H12.4118V9.64706H9.64706V19.0588H12.5882V14.0529C12.5457 13.5403 12.7072 13.0315 13.0376 12.6372C13.3681 12.2429 13.8407 11.9949 14.3529 11.9471H14.4647C15.4 11.9471 16.0941 12.5353 16.0941 14.0176V19.0588H19.0353L19.0588 13.3412Z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,21 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...Astro.props}
>
<path
fill="currentColor"
d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 2.4c5.302 0 9.6 4.298 9.6 9.6S17.302 21.6 12 21.6 2.4 17.302 2.4 12 6.698 2.4 12 2.4z"
></path>
<path
fill="currentColor"
d="M12 6c-3.314 0-6 2.686-6 6s2.686 6 6 6 6-2.686 6-6-2.686-6-6-6zm0 2.4c1.988 0 3.6 1.612 3.6 3.6S13.988 16.8 12 16.8 8.4 15.188 8.4 13.2 10.012 9.6 12 9.6z"
></path>
<path
fill="currentColor"
d="M8.4 10.8h7.2v2.4H8.4v-2.4zm-1.2-1.2h9.6v4.8H7.2V9.6z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 655 B

View File

@ -0,0 +1,18 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...Astro.props}
>
<path
fill="currentColor"
d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 2.4c5.302 0 9.6 4.298 9.6 9.6S17.302 21.6 12 21.6 2.4 17.302 2.4 12 6.698 2.4 12 2.4z"
></path>
<path
fill="currentColor"
d="M12 6c-3.314 0-6 2.686-6 6s2.686 6 6 6 6-2.686 6-6-2.686-6-6-6zm0 2.4c1.988 0 3.6 1.612 3.6 3.6S13.988 16.8 12 16.8 8.4 15.188 8.4 13.2 10.012 9.6 12 9.6z"
></path>
<circle fill="currentColor" cx="16.8" cy="7.2" r="1.2"></circle>
</svg>

After

Width:  |  Height:  |  Size: 617 B

View File

@ -0,0 +1,12 @@
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...Astro.props}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M13.3393 0.582135C12.6142 -0.194045 11.3836 -0.194045 10.6584 0.582135L8.88012 2.48429C8.51756 2.8711 8.00564 3.0843 7.47584 3.06515L4.87538 2.97706C3.81324 2.94132 2.94259 3.81197 2.97834 4.87411L3.06642 7.47712C3.0843 8.00691 2.87238 8.51884 2.48429 8.88139L0.582135 10.6584C-0.194045 11.3836 -0.194045 12.6155 0.582135 13.3406L2.48429 15.1189C2.87238 15.4815 3.0843 15.9921 3.06642 16.5232L2.97706 19.1249C2.94259 20.1871 3.81324 21.0577 4.87538 21.022L7.47712 20.9339C8.00691 20.916 8.51884 21.1279 8.88139 21.5148L10.6584 23.4169C11.3848 24.1944 12.6155 24.1944 13.3419 23.4169L15.1202 21.5148C15.4815 21.1279 15.9934 20.9147 16.5232 20.9339L19.1249 21.022C20.1871 21.0577 21.059 20.1871 21.022 19.1249L20.9352 16.5219C20.916 15.9921 21.1292 15.4815 21.516 15.1189L23.4182 13.3406C24.1944 12.6155 24.1944 11.3836 23.4182 10.6584L21.516 8.88012C21.1292 8.51884 20.916 8.00691 20.9352 7.47584L21.022 4.87411C21.059 3.81197 20.1871 2.94132 19.1249 2.97706L16.5232 3.06642C15.9934 3.08302 15.4815 2.8711 15.1189 2.48429L13.3393 0.582135ZM5.91327 12.5402L10.2908 16.9164L17.5458 8.99374L15.8262 7.4018L10.2091 13.5232L7.56393 10.878L5.91327 12.5402Z"
fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,49 @@
---
import { buttonVariants } from "@/lib/utils";
export interface Props {
href: string;
ariaLabel?: string;
disabled?: boolean;
className?: string;
target?: string;
variant?: "default" | "ghost" | "secondary";
}
const {
href,
ariaLabel,
target,
className,
disabled = false,
variant = "ghost",
} = Astro.props;
---
{
disabled ? (
<span
class:list={[
buttonVariants({ size: "sm", variant }),
"rounded-full text-muted-foreground hover:bg-transparent hover:text-muted-foreground [&_svg]:size-4",
className,
]}
aria-disabled={disabled}
>
<slot />
</span>
) : (
<a
{href}
{target}
class:list={[
buttonVariants({ size: "sm", variant }),
"!rounded-full",
className,
]}
aria-label={ariaLabel}
>
<slot />
</a>
)
}

View File

@ -0,0 +1,22 @@
---
import { ArrowRight } from "lucide-react";
import { capitalizeFirstLetter } from "@/lib/utils";
interface Props {
href: string;
tag: string;
}
const { href, tag } = Astro.props;
---
<a
href={href}
class="group flex items-center gap-x-1 rounded-sm decoration-muted-foreground underline-offset-4 duration-150 hover:underline"
>
<span>#{tag}</span>
<ArrowRight
className="size-3.5 scale-0 -rotate-45 duration-75 group-hover:scale-100"
/>
</a>

View File

@ -0,0 +1,12 @@
---
import { markdownToHtml } from "@/lib/markdown";
interface Props {
className?: string;
content: string;
}
const { className, content } = Astro.props;
---
<div set:html={markdownToHtml(content, className)} />

View File

@ -0,0 +1,20 @@
---
interface Props {
className?: string;
}
const { className } = Astro.props;
---
<div
class:list={[
"screen-line-before screen-line-after relative flex h-4 w-full md:border-x",
"before:absolute before:-left-[100vw] before:h-full before:w-[200vw]",
// Replace the striped background with dotted pattern
"before:bg-[image:radial-gradient(var(--pattern-foreground)_2px,_transparent_2px)] before:bg-[size:15px_15px]",
"before:[--pattern-foreground:var(--color-black)]/5 dark:before:[--pattern-foreground:var(--color-white)]/5",
className,
]}
>
<slot />
</div>

36
src/content.config.ts Normal file
View File

@ -0,0 +1,36 @@
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/data/blog" }),
schema: () =>
z.object({
pubDatetime: z.date(),
series: z.string().optional(),
image: z.string().optional(),
title: z.string(),
draft: z.boolean().optional(),
tags: z.array(z.string()).default(["others"]),
category: z.string(),
description: z.string(),
}),
});
const projects = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/data/projects" }),
schema: () =>
z.object({
name: z.string(),
technologies: z.array(z.string()),
description: z.string(),
image: z.string().optional(),
sourceCode: z.string().optional(),
preview: z.string().optional(),
type: z.union([
z.literal("core"),
z.literal("side"),
])
}),
});
export const collections = { blog, projects };

View File

@ -0,0 +1,36 @@
---
name: CSR Purwakarta 🏙️🤝
type: core
preview: https://csr.purwakarta.go.id
technologies:
- Laravel
- Livewire
- Filament
- AlpineJS
- TailwindCSS
- MySQL
description: A centralized platform for managing Corporate Social Responsibility (CSR) initiatives in Purwakarta, fostering transparency and collaboration between the government and partners. 🏙️🤝
---
![Portfolio Preview](/og-images/projects/csr.png)
## Overview
**CSR Purwakarta** is the digital heart of social responsibility in the region! ❤️ This platform is built to streamline and showcase the amazing Corporate Social Responsibility (CSR) contributions from companies to the community of Purwakarta.
It serves as a transparent ecosystem where the government, companies (partners), and the public can see exactly how funds are being used to build a better future! Key stakeholders can propose projects, report completed works, and track the overall impact through data-rich statistics. 📊✨
## 🚀 Why It Matters
* **Transparency First**: Openly displays how CSR funds are allocated and realized, building public trust. 🔍
* **Collaboration Hub**: Connects government agencies (Dinas/SKPD) directly with corporate partners for impactful projects. 🔗
* **Data-Driven Impact**: Visual statistics show the real-world difference being made across different sectors. 📈
## ✨ Key Features
* 🏢 **Partner Management**: A comprehensive database of companies and partners contributing to the region's development.
* 📝 **Proposal System**: Dinas and SKPD can submit proposals for CSR funding directly through the system.
* 📢 **Activity Reporting**: Partners can easily report and showcase the CSR activities they have completed.
* 📊 **Live Statistics**: Real-time charts and graphs displaying fund realization, project counts, and sector distribution.
* 📰 **News & Updates**: A dedicated section to highlight success stories and ongoing CSR initiatives.
* 🗺️ **Project Tracking**: Monitor the progress of approved CSR programs from start to finish!
---
*Building a better Purwakarta, together!* 🌏👷‍♂️

View File

@ -0,0 +1,33 @@
---
name: Dinkes - Management of 3 Flagship Community Health Centers 🏥💉
type: core
preview: https://puskesmas-tegalwaru.pratama.live
technologies:
- Laravel
- Livewire
- Filament
- AlpineJS
- TailwindCSS
- MySQL
description: A comprehensive management system for public health centers (Puskesmas) under the Health Office, featuring service information, blog, and internal management. 🏥💉
---
## Overview
This comprehensive system is the digital backbone for operational needs across **three flagship Puskesmas (Public Health Centers)** under the Health Office! 🚑 It goes beyond simple administration, serving as a dynamic platform for both **internal management** and **public service information**.
A true health-tech solution, it allows the Health Office (Dinas Kesehatan) to monitor activities, track performance, and ensure that healthcare services are delivered efficiently to the community! 💊✨
## 🚀 Key Objectives
* **Centralized Health Monitoring**: A unified view for the Health Office to oversee multiple health centers. 🧐
* **Public Awareness**: Keeping the community informed about health services, schedules, and important news. 📢
* **Operational Efficiency**: Streamlining the daily workflow of healthcare providers. ⚡
## ✨ Key Features
* 🏥 **Service Management**: Detailed information on available health services and schedules across centers.
* 📝 **Integrated Blog & News**: A platform to share health tips, community updates, and official announcements.
* 📊 **Activity Monitoring**: Tracking daily operational activities and healthcare service delivery.
* 👥 **Personnel Data**: Managing staff information and rosters for each health center.
* 📈 **Performance Dashboard**: Analyzing key health metrics and operational data for better decision-making.
---
*Empowering community health through digital management!* 💚🤝

View File

@ -0,0 +1,32 @@
---
name: DST Collection Garment/Convection Management System 🧵👕
type: core
preview: https://konveksi.dstpabuaran.com
technologies:
- Laravel
- Javascript
- Bootstrap
- MySQL
description: An application for managing garment production workflows, from pattern design to final delivery. 🧵👕
---
## Overview
**DST Collection** is a dedicated management system for **garment production (convection)**! 🏭 This powerful tool is designed to optimize every step of the garment manufacturing lifecycle.
From tracking raw materials and fabrics 🧵 to monitoring each stage of production (cutting, sewing, finishing) ✂️, it ensures that every piece of clothing meets quality standards and deadlines! It's the digital partner for a clothing manufacturing business. 👔
## 🚀 Key Objectives
* **Production Excellence**: Ensuring high-quality garment output by tracking each process step. 📉
* **Material Efficiency**: Minimizing waste by managing fabric inventory accurately. 📦
* **Order Fulfillment**: Delivering customer orders on time, every time. 🚚
## ✨ Key Features
* 📏 **Production Workflow**: Digitalized tracking of the garment manufacturing lifecycle (Design, Pattern, Cut, Sew).
* 🧵 **Raw Material Management**: Inventory for fabrics, threads, buttons, and other production needs.
* 📦 **Process Monitoring**: Real-time insights into the progress of each batch or order.
* 👥 **Employee Tracking**: Assigning tasks and monitoring the productivity of production staff.
* 🛒 **Order Management**: Tracking incoming orders and their current status in the pipeline.
* 💼 **Finished Goods**: Inventory management for completed garments ready for delivery.
---
*Stitching efficiency into every garment!* 👗✨

View File

@ -0,0 +1,37 @@
---
name: Jelita Florist 🌸🌷
type: core
preview: https://tokobungapurwakarta.com
technologies:
- Laravel
- Livewire
- Filament
- AlpineJS
- TailwindCSS
- MySQL
- WhatsApp Integration
description: A beautiful and integrated flower shop management system featuring a stunning landing page, article management, stock control, and powerful analytics. 🌸🌷
---
![Portfolio Preview](/og-images/projects/jelita-florist.png)
## Overview
**Jelita Florist** isn't just a website; it's a flourishing digital garden for business! 🌿 This platform combines a visually captivating **Landing Page** to attract customers with a powerful backend to manage the entire floral business. From tracking every petal in stock to analyzing sales trends, this system helps the business bloom! 🌺
It's designed to be **user-friendly yet powerful**, giving the owner complete control over their products, content, and customer relationships. 🤝
## 🚀 Why It Stands Out
* **Stunning First Impression**: A beautiful landing page that showcases floral arrangements in all their glory. ✨
* **Content is King**: An integrated article/blog section to share flower care tips, news, and promotions! 📝
* **Business Intelligence**: A dashboard that turns data into actionable insights with easy-to-read charts and reports. 📊
## ✨ Key Features
* 🌐 **Captivating Landing Page**: Designed to wow visitors and convert them into customers.
* 🛒 **Stock & Sales Management**: Keep track of inventory and sales seamlessly—no more guessing games!
* 👥 **Customer Management**: Build lasting relationships by managing customer data effectively.
* 📰 **Article & Blog System**: Drive engagement and SEO with a built-in content management system.
* 📈 **Smart Dashboard**: A central hub for easy analysis and comprehensive reports on business performance.
* 📱 **WhatsApp Integration**: Direct connection for instant customer inquiries and orders! 💬
---
*Helping the floral business grow, one blossom at a time!* 🌻💼

View File

@ -0,0 +1,33 @@
---
name: Catalyst Indonesia 🏢✨
type: core
preview: https://katalis-indonesia.com
technologies:
- Laravel
- Javascript
- Bootstrap
- MySQL
description: A clean and professional company profile website developed to present corporate identity and services. 🏢✨
---
![Portfolio Preview](/og-images/projects/katalis.png)
## Overview
**Catalyst Indonesia** is the digital face of the company! 🌐💼 This application is a sleek and professional **Company Profile** website designed to make a strong corporate statement.
Beyond a simple web presence, it's a strategic platform for communicating the company's **Mission, Vision, and Values** to potential clients and partners! 🤝 With detailed service pages and a clean aesthetic, it establishes credibility and trust instantly. ⭐
## 🚀 Key Objectives
* **Professional Branding**: Reflecting the company's identity and values through a cohesive design. 🎨
* **Service Showcase**: clearly detailing the products and services offered to the market. 🛠️
* **Customer Trust**: Building confidence through a transparent and professional online presence. ✅
## ✨ Key Features
* 🏢 **Corporate Identity**: Dedicated sections for Mission, Vision, and Leadership Team information.
* 🛍️ **Services Portfolio**: Detailed catalog of services and offerings with descriptions.
* 📝 **News & Updates**: A blog function to share the latest company news and industry insights.
* 📧 **Contact Form**: An easy way for potential clients to get in touch. ✉️
* 📱 **Responsive Design**: Optimized for mobile and desktop for a professional look on any device.
---
*Elevating corporate presence with digital excellence!* 🌟📈

View File

@ -0,0 +1,34 @@
---
name: PPID Purwakarta 🏛️📂
type: core
preview: https://ppid.purwakarta.go.id
technologies:
- Laravel
- PHP
- MySQL
- Bootstrap
- Javascript
description: A digital transparency platform for the Regional Information Management Officer (PPID), facilitating public access to government documents and information requests. 🏛️📂
---
![Portfolio Preview](/og-images/projects/ppid.png)
## Overview
**PPID Purwakarta** is the digital gateway to transparency for the Regional Government! 🚪✨ It serves as the official platform for the **Pejabat Pengelola Informasi dan Dokumentasi (PPID)**, ensuring that the public has easy access to open government information.
This application is all about **accountability and openness**. It allows citizens to submit information requests online and browse through a vast repository of public documents, making government data accessible to everyone with just a few clicks! 🖱️✅
## 🚀 Key Objectives
* **Public Transparency**: Fulfilling the right of the public to access information in a transparent manner. 🔍
* **Efficient Service**: Streamlining the process of information requests from manual to digital. ⚡
* **Structured Archiving**: Organizing official documents and regulations for easy retrieval. 🗂️
## ✨ Key Features
* 📝 **Online Information Requests**: Citizens can easily submit and track requests for specific information.
* 📂 **Document Repository**: A structured library of public documents, regulations, and official reports.
* ⚖️ **Objection Submission**: A formal channel for the public to file objections regarding information services.
* 📊 **Service Statistics**: Real-time data on the number of requests and their status.
* 📰 **News & Announcement**: official updates and news from the information officer.
---
*Opening the doors to government transparency!* 🇮🇩📜

View File

@ -0,0 +1,33 @@
---
name: Sibadeksa 🏞️📊
type: core
preview: https://sibadeksa.purwakarta.go.id
technologies:
- Laravel
- Javascript
- Bootstrap
- MySQL
description: An Integrated Database Information System for Economic Sector Development and Natural Resources, centralizing regional data. 🏞️📊
---
![Portfolio Preview](/og-images/projects/sibadeksa.png)
## Overview
**Sibadeksa** is the digital cornerstone for regional planning! 🏛️ It stands for **Sistem Informasi Basis Data Terpadu Pembangunan Sektor Perekonomian dan Sumber Daya Alam**. This powerful platform centralizes crucial datasets related to the **Economy** 💰 and **Natural Resources** 🌳 of the region.
Designed for government planners and analysts, it serves as a unified repository for data across various sectors from agriculture 🌾 to industry 🏭! With visual analytics, it empowers decision-makers to craft informed policies for sustainable development. 📈🌍
## 🚀 Key Objectives
* **Data Integration**: Breaking down silos by centralizing data from various economic and resource sectors. 🔗
* **Informed Policy**: Enabling evidence-based decision-making for regional development plans. 💡
* **Monitoring Progress**: Tracking the growth and utilization of resources over time. ⏳
## ✨ Key Features
* 🗄️ **Centralized Dataset**: A unified database for economic indicators and natural resource management.
* 🌾 **Sector Data Management**: Dedicated modules for Agriculture, Industry, Trade, and Natural Resources.
* 📊 **Visual Analytics**: Interactive charts and graphs to visualize development trends and performance.
* 🗺️ **Resource Mapping**: Geospatial insights into the distribution of natural resources.
* 📑 **Report Generation**: Automated reports for government planning meetings and evaluations.
---
*Data-driven development for a prosperous region!* 🇮🇩💪

View File

@ -0,0 +1,36 @@
---
name: Simekdom 📰🤝
type: core
preview: https://simedkom.pratama.live
technologies:
- Laravel
- Livewire
- Filament
- AlpineJS
- TailwindCSS
- MySQL
description: An integrated media communication system facilitating seamless collaboration and monitoring between local government and media partners. 📰🤝
---
![Portfolio Preview](/og-images/projects/simedkom.png)
## Overview
**Simekdom** is the ultimate bridge between the local government and the media world! 🌉 This powerful platform is designed to manage and monitor the dynamic relationship between government institutions and their media partners.
Its not just about tracking news; its about **fostering collaboration**! From managing partnerships to monitoring media coverage, Simekdom ensures that communication is transparent, efficient, and well-documented. Plus, with built-in analytics, the government can see exactly how their message is reaching the public! 📢📊
## 🚀 Why It's Impactful
* **Government-Media Synergy**: Creates a structured environment for cooperation, making it easier to work together. 🤝
* **Data-Driven Decisions**: The dashboard provides real-time insights into media performance and coverage. 💡
* **Seamless Communication**: Integrated email messaging ensures that no important update gets lost in the shuffle! 📧
## ✨ Key Features
* 📺 **Media Monitoring**: Keep a close eye on news coverage and media activities related to the government.
* 🤝 **Partnership Management**: efficiently manage relationships with various media outlets and partners.
* 📝 **Collaboration Hub**: A dedicated space to handle agreements, contracts, and joint activities.
* 📊 **Dashboard & Analytics**: Visualize data with intuitive charts to evaluate media impact and reach.
* 📰 **Integrated Blog**: Publish official updates, press releases, and news directly from the system.
* 📧 **Contact & Email Messaging**: sophisticated 2-way communication system with email reply capabilities! 📬
---
*Empowering transparent and effective government communication!* 🏛️📡

View File

@ -0,0 +1,33 @@
---
name: VNGrup Multi-Business 🧸👗
type: core
preview: https://vngrup.com
technologies:
- Laravel
- Javascript
- Bootstrap
- WhatsApp Integration
- MySQL
description: A suite of applications for managing different business lines of VNGrup, including toys and clothing, featuring internal management and WhatsApp purchase integration.
---
## Overview
**VNGrup** is a multi-faceted business ecosystem that powers the operations of various sectors under the **VN** brand! 🚀 This suite of applications is tailored to handle the unique needs of **Toy Stores** and **Clothing Retail**.
While the system is robust for **internal management**—tracking stock, sales, and employee activities—it also features a **Customer-Facing Landing Page** that showcases products. When a customer is ready to buy, the system seamlessly redirects them to **WhatsApp** for a personalized purchase experience! 📲💬
## 🚀 Key Objectives
* **Diverse Business Management**: Unified platform for managing disparate business lines (Toys & Fashion). 🎭
* **Internal Efficiency**: Streamlining the backend operations for inventory and staff management. ⚙️
* **Direct-to-Customer Sales**: Bridging the gap between browsing and buying through direct WhatsApp communication. 🛍️
## ✨ Key Features
* 🧸 **Toy Business Management**: Specialized tools for managing toy inventory and sales.
* 👗 **Clothing Retail System**: Dedicated features for tracking garment stock and fashion trends.
* 🌐 **Product Landing Page**: A showcase for the business's best products to attract customers.
* 📱 **WhatsApp Purchase Flow**: Redirects interested buyers directly to WhatsApp for a smooth transaction.
* 📦 **Inventory Control**: Real-time tracking of stock levels across different business units.
* 👥 **Employee Management**: Tools for monitoring staff shifts and performance.
---
*Managing diverse businesses with unified digital power!* 🌟💼

View File

@ -0,0 +1,38 @@
---
name: Webdesaku 🏘️🛒
type: core
preview: https://cadassari.desa.id
technologies:
- Laravel
- Livewire
- Javascript
- Bootstrap
- MySQL
- RajaOngkir
- Xendit
description: A powerful e-commerce platform for Village-Owned Enterprises (BUMDes) and a hub for public information transparency, integrated with logistics and payment gateways. 🏘️🛒
---
![Portfolio Preview](/og-images/projects/webdesaku.png)
## Overview
**Webdesaku** is a revolutionary digital platform that empowers villages! 🚀 It serves a dual purpose: driving economic growth through **Village-Owned Enterprises (BUMDes)** e-commerce and ensuring **public information transparency** for the community.
This system is a game-changer for local economies, providing a professional online marketplace for village products while also serving as a trusted source of official village news and data. Plus, it seamlessly integrates with **Pusdatin** (Village Data Center) for unified data management! 🌐📊
## 🚀 Why It's Essential
* **Economic Empowerment**: transform local village businesses into digital powerhouses, reaching a wider market! 💰
* **Modern Convenience**: Integrated with **RajaOngkir** for automatic shipping calculations and **Xendit** for secure digital payments. 🚚💳
* **Transparent Governance**: Keeps the community informed with open access to village data, news, and official announcements. 📢
## ✨ Key Features
* 🛒 **BUMDes E-Commerce**: A full-featured online store for managing products, sales, and orders efficiently.
* 📦 **Logistics Integration**: Real-time shipping cost calculation with **RajaOngkir** API.
* 💳 **Secure Payments**: Frictionless transactions with **Xendit** payment gateway integration.
* 📊 **Smart Dashboard**: Comprehensive analytics on sales, visitor stats, and village data.
* 📰 **Village Information Hub**: Publish news, announcements, and public documents for transparency.
* 🔗 **Pusdatin Integration**: Seamlessly syncs with the central village data application for accurate records.
* 📝 **Content Management**: Easy-to-use tools for managing blogs, products, and village profiles.
---
*Digitalizing village economies and connecting communities!* 🌍🤝

View File

@ -0,0 +1,50 @@
---
name: Yadi Parfum 🌸✨
type: core
preview: https://yadiparfum.com
technologies:
- Laravel
- Livewire
- AlpineJS
- TailwindCSS
- MySQL
- Flux UI
description: A comprehensive digital management system for the perfume business, handling sales, inventory, finance, and customer loyalty programs. 🌸✨
---
![Portfolio Preview](/og-images/projects/yadi-parfum.png)
## Overview
**Yadi Parfum** is more than just a management system; it's a complete digital upgrade for the perfume business! 🚀 Designed to streamline everything from inventory significantly to sales, this platform ensures that business operations run as smoothly as a fine fragrance.
Weve built a robust system that handles the nitty-gritty of **finance, stock, and payroll**, while also spicing things up with **membership and voucher features** to keep customers coming back for more! 😍
Plus, we're not stopping here! The system is built ready for the future, with plans to evolve into a full-blown **online shopping platform**! 🛒💨
## 🚀 Digital Transformation Success
This project is a shining example of **successful digital transformation**! ✨ By moving away from manual processes to this all-in-one digital solution, Yadi Parfum has leveled up their game.
* **No more headaches** with manual tracking. 🤯➡️😌
* **Efficiency is through the roof!** 📈
* Business owners can now focus on **growing their brand** instead of getting lost in paperwork.
## 💡 Why Users Love It
The impact on the user experience has been huge! Heres why its a game-changer:
* ⚡ **Lightning-Fast Analysis**: Real-time data means decisions can be made in a snap.
* 📊 **Complete & Clear Reports**: No more guessing games; every penny and perfume bottle is accounted for.
* 🧘 **Peace of Mind**: Automated payroll and stock alerts mean fewer worries.
* 🤝 **Happier Customers**: The loyalty system makes customers feel special and valued. 💖
## ✨ Key Features
* 🧃**Product Mastery**: Effortlessly organize your perfume catalog with variants, pricing, and details.
* 🏭 **Warehouse Command**: Advanced control over stock movements and multiple storage locations.
* 📦 **Smart Inventory**: Track raw materials and stock levels in real-time.
* 💰 **Financial Wizardry**: Keep a close eye on income, expenses, and profits without the stress.
* 💳 **Seamless Sales (POS)**: A smooth checkout experience supporting multiple payment methods.
* 📝 **Integrated Blog**: Share perfume tips and business updates directly with your audience!
* 🎁 **Loyalty & Vouchers**: Engage customers with memberships and exciting discounts.
* 👥 **Payroll Made Easy**: Manage employee salaries and performance effortlessly.
* 📲 **Realtime Notifications**: Stay updated instantly with alerts for sales and stock levels! 📲
* 🔜 **Future E-Commerce**: Ready to expand into online sales! 🌐
---
*Bringing the sweet scent of success to business management!* 🌹💼

132
src/layouts/layout.astro Normal file
View File

@ -0,0 +1,132 @@
---
import "@fontsource/geist-sans";
import "@fontsource/geist-sans/500.css";
import "@fontsource/geist-sans/600.css";
import "@fontsource/geist-sans/700.css";
import "@fontsource/geist-sans/800.css";
import "@fontsource/geist-sans/900.css";
import "@fontsource/roboto-condensed/600.css";
import "@fontsource/roboto-condensed/400.css";
import "@fontsource/jetbrains-mono/400.css";
import { ClientRouter } from "astro:transitions";
import Analytics from "@vercel/analytics/astro";
import { SITE } from "@/lib/config";
import "@/styles/global.css";
import BackToTop from "@/components/back-to-top.astro";
export interface Props {
title?: string;
author?: string;
profile?: string;
description?: string;
ogImage?: string;
pageType?: string;
canonicalURL?: string;
}
const {
title = SITE.title,
author = SITE.author,
profile = SITE.profile,
description = SITE.desc,
ogImage = SITE.ogImage,
pageType = SITE.pageType,
canonicalURL = new URL(Astro.url.pathname, Astro.url),
} = Astro.props;
const socialImageURL = new URL(ogImage ?? SITE.ogImage, Astro.site).toString();
const structuredData = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: `${title}`,
image: `${socialImageURL}`,
author: [
{
"@type": "Person",
name: `${author}`,
...(profile && { url: profile }),
},
],
};
---
<script>
document.addEventListener("astro:after-swap", () => {
const getThemePreference = () => {
if (
typeof localStorage !== "undefined" &&
localStorage.getItem("theme")
) {
return localStorage.getItem("theme");
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
};
const isDark = getThemePreference() === "dark";
document.documentElement.classList[isDark ? "add" : "remove"]("dark");
if (typeof localStorage !== "undefined") {
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
}
});
</script>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<!-- <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> -->
<link rel="canonical" href={canonicalURL} />
<meta name="generator" content={Astro.generator} />
<!-- General Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta name="author" content={author} />
<link rel="sitemap" href="/sitemap-index.xml" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content={pageType} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={socialImageURL} />
<meta property="og:url" content={canonicalURL} />
<!-- X / Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalURL} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={socialImageURL} />
<ClientRouter />
<!-- Google JSON-LD Structured data -->
<script
type="application/ld+json"
is:inline
set:html={JSON.stringify(structuredData)}
/>
<meta name="theme-color" content="" />
</head>
<body class="relative min-h-screen overflow-x-hidden">
<slot />
<BackToTop />
<Analytics />
</body>
</html>

View File

@ -0,0 +1,57 @@
---
import { capitalizeFirstLetter } from "@/lib/utils";
import Layout from "./layout.astro";
import Navbar from "@/components/navbar/index.astro";
import Footer from "@/components/footer.astro";
import LinkBtn from "@/components/ui/link-btn.astro";
import Pattern from "@/components/ui/pattern.astro";
import Box from "@/components/box/index.astro";
import BoxTitle from "@/components/box/title.astro";
import BoxHeader from "@/components/box/header.astro";
import BoxContent from "@/components/box/content.astro";
import { ArrowLeft } from "lucide-react";
interface Props {
title: string;
description: string;
backLink?: string;
}
const { title, description, backLink } = Astro.props;
---
<Layout title={`${title}`}>
<Navbar />
<main class="mx-auto min-h-screen max-w-3xl" aria-label="Main content">
<Pattern className="h-14">
{
backLink && (
<LinkBtn
ariaLabel="Back link"
className="absolute bottom-0 left-2 z-1"
href={backLink}
>
<ArrowLeft />
</LinkBtn>
)
}
</Pattern>
<Box>
<BoxHeader>
<BoxTitle className="text-2xl !font-black"
>{capitalizeFirstLetter(title)}</BoxTitle
>
</BoxHeader>
<BoxContent>
{description}
</BoxContent>
</Box>
<slot />
</main>
<Footer />
</Layout>

View File

@ -0,0 +1,104 @@
---
import Footer from "@/components/footer.astro";
import Navbar from "@/components/navbar/index.astro";
import { capitalizeFirstLetter } from "@/lib/utils";
import { Icon } from "astro-icon/components";
import Layout from "./layout.astro";
import Pattern from "@/components/ui/pattern.astro";
import BoxContent from "@/components/box/content.astro";
import BoxHeader from "@/components/box/header.astro";
import Box from "@/components/box/index.astro";
import BoxTitle from "@/components/box/title.astro";
import Badge from "@/components/ui/badge.astro";
import LinkBtn from "@/components/ui/link-btn.astro";
interface Props {
title: string;
technologies: string[];
sourceCode?: string;
preview?: string;
image?: string;
description: string;
}
const { title, technologies, description, preview, sourceCode, image } =
Astro.props;
---
<Layout title={`${title}`} ogImage={image} description={description}>
<Navbar />
<main class="mx-auto min-h-screen max-w-3xl" aria-label="Main content">
<Pattern className="h-14">
<div class="z-1 mx-2 my-auto flex w-full items-center justify-between">
<LinkBtn ariaLabel="Back btn" href="/projects">
<Icon name="lucide:arrow-left" class="size-4" />
</LinkBtn>
<div>
{
sourceCode && (
<LinkBtn
ariaLabel="Source code"
target="_blank"
className="text-xs"
variant="default"
href={sourceCode}
>
Source
<Icon name="logos:github" class="size-3" />
</LinkBtn>
)
}
{
preview && (
<LinkBtn
ariaLabel="Deployed App"
variant="default"
target="_blank"
className="text-xs"
variant="default"
href={preview}
>
Show
<Icon name="lucide:external-link" class="size-3" />
</LinkBtn>
)
}
</div>
</div>
</Pattern>
<Box>
<BoxHeader>
<BoxTitle className="text-2xl !font-black md:text-3xl"
>{capitalizeFirstLetter(title)}</BoxTitle
>
</BoxHeader>
<BoxContent className="space-y-4">
<div class="flex flex-wrap gap-1.5">
{
technologies.map(technology => {
return <Badge>{technology}</Badge>;
})
}
</div>
</BoxContent>
</Box>
<Pattern />
<Box className="min-w-full">
<BoxContent className="pt-8">
<article class="prose min-w-full">
<slot />
</article>
</BoxContent>
</Box>
</main>
</Layout>
<Footer />

9
src/lib/config.ts Normal file
View File

@ -0,0 +1,9 @@
export const SITE = {
pageType: 'website',
author: "Yoga Pangestu",
profile: "https://pangestu.vercel.app",
desc: "A digital space to share my professional experiences and work.",
title: "Yoga Pangestu",
ogImage: '/og-image.jpg',
postPerPage: 6,
} as const;

View File

@ -0,0 +1,50 @@
import type { Experience } from "../types";
export const EXPERIENCES: Experience[] = [
{
company: "PT Pratama Solusi Teknologi",
positions: [
{
title: "Full Stack Developer",
year: "May 01, 2023 - Present",
description: [
"Developed and maintained multiple web applications for business clients and local government institutions.",
"Implemented RESTful APIs for internal systems and third-party integrations.",
"Involved in application architecture design from requirement analysis through deployment.",
"Handled deployment and configuration on production servers.",
"Collaborated with developers and stakeholders to deliver features aligned with business needs.",
],
},
],
},
{
company: "Freelance | Locally",
positions: [
{
title: "MERN Stack Developer",
year: "May 01, 2024 - Present",
description: [
"Developed custom web applications based on client requirements, translating business needs into scalable technical solutions.",
"Assisted local business owners in digitizing their operations through tailored web systems and automation tools.",
"Designed and implemented RESTful APIs for frontend-backend communication and third-party integrations.",
"Deployed applications to production environments and handled basic server configuration and maintenance.",
"Provided ongoing support, feature enhancements, and performance optimization for client projects.",
],
},
],
},
{
company: "Yadi Parfum",
positions: [
{
title: "Tim Leader",
year: "April 01, 2023 - May 01, 2025",
description: [
"Developed a business operation system covering inventory, payroll, transactions, and employee data.",
"Assisted in digitizing operational processes previously handled manually.",
"Coordinated daily operational needs and supported team collaboration.",
],
},
],
},
];

View File

@ -0,0 +1,14 @@
export const NAV_LINKS = [
{
label: "Home",
href: "/",
},
{
label: "Projects",
href: "/projects",
},
{
label: "Contact",
href: "/contact",
},
] as const;

View File

@ -0,0 +1,13 @@
export const PROFILE_INFO = {
role: "Full Stack Developer",
logo: "Yoga Pangestu",
slogan: "I want to be a superman.",
displayName: "Yoga Pangestu",
email: "info.pangestuyoga@gmail.com",
about: `
I'm a Full Stack Developer with over 3 years of experience in web application development, covering all stages of the development process, including system design, development, testing, and deployment. I work with a structured approach and pay close attention to code quality, performance, and application security.
<br/>
I have a strong curiosity about emerging technologies, enjoy continuous learning, and adapt quickly to new tools and frameworks. I am comfortable working independently as well as collaborating within teams to deliver effective and sustainable digital solutions.
`,
} as const;

View File

@ -0,0 +1,101 @@
export const TECH_STACK = [
// Backend Framework
{
title: "Laravel",
href: "https://laravel.com/",
icon: "logos:laravel",
},
{
title: "Livewire",
href: "https://livewire.laravel.com/",
icon: "devicon:livewire",
},
{
title: "Filament",
href: "https://filamentphp.com/",
icon: "fluent-color:lightbulb-filament-16",
},
// Programming Languages
{
title: "JavaScript",
href: "https://developer.mozilla.org/en-US/docs/Web/JavaScript",
icon: "logos:javascript",
},
{
title: "Python",
href: "https://www.python.org/",
icon: "logos:python",
},
// Frontend Technologies
{
title: "HTML5",
href: "https://developer.mozilla.org/en-US/docs/Web/HTML",
icon: "logos:html-5",
},
{
title: "CSS",
href: "https://developer.mozilla.org/en-US/docs/Web/CSS",
icon: "skill-icons:css",
},
{
title: "Astro",
href: "https://astro.build/",
icon: "devicon:astro",
},
{
title: "Vue.js",
href: "https://vuejs.org/",
icon: "logos:vue",
},
{
title: "Alpine.js",
href: "https://alpinejs.dev/",
icon: "devicon:alpinejs",
},
{
title: "Tailwind CSS",
href: "https://tailwindcss.com/",
icon: "devicon:tailwindcss",
},
{
title: "Bootstrap",
href: "https://getbootstrap.com/",
icon: "logos:bootstrap",
},
{
title: "Flux UI",
href: "https://fluxui.dev/",
icon: "logos:flux",
},
// Databases
{
title: "MySQL",
href: "https://www.mysql.com/",
icon: "logos:mysql",
},
{
title: "PostgreSQL",
href: "https://www.postgresql.org/",
icon: "logos:postgresql",
},
// Development Tools
{
title: "Git",
href: "https://git-scm.com/",
icon: "devicon:git",
},
{
title: "VS Code",
href: "https://code.visualstudio.com/",
icon: "logos:visual-studio-code",
},
{
title: "GitHub",
href: "https://github.com/",
icon: "devicon:github",
},
];

13
src/lib/markdown.ts Normal file
View File

@ -0,0 +1,13 @@
// @ts-ignore
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt({
html: true,
});
export function markdownToHtml(markdown: string, className?: string): string {
md.renderer.rules.paragraph_open = () => `<p class="${className}">`;
return md.render(markdown);
}

10
src/lib/supabase.ts Normal file
View File

@ -0,0 +1,10 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.SUPABASE_URL || process.env.SUPABASE_URL;
const supabaseAnonKey = import.meta.env.SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
console.warn('Warning: Supabase credentials are not defined in environmental variables.');
}
export const supabase = createClient(supabaseUrl || '', supabaseAnonKey || '');

10
src/lib/types.ts Normal file
View File

@ -0,0 +1,10 @@
export type ExperiencePosition = {
title: string;
year: string;
description: string[];
};
export type Experience = {
company: string;
positions: ExperiencePosition[];
};

57
src/lib/utils.ts Normal file
View File

@ -0,0 +1,57 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { cva } from "class-variance-authority";
import calculateReadingTime from 'reading-time';
import { fromMarkdown } from 'mdast-util-from-markdown';
import { toString } from 'mdast-util-to-string';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function capitalizeFirstLetter(sentence: string): string {
return `${sentence.charAt(0).toUpperCase()}${sentence.slice(1)}`;
}
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export const getReadingTime = (text: string): string | undefined => {
if (!text || !text.length) return undefined;
try {
const { minutes } = calculateReadingTime(toString(fromMarkdown(text)));
if (minutes && minutes > 0) {
return `${Math.ceil(minutes)} min read`;
}
return undefined;
} catch (e) {
return undefined;
}
};

228
src/pages/contact.astro Normal file
View File

@ -0,0 +1,228 @@
---
import Layout from "@/layouts/page-layout.astro";
import Box from "@/components/box/index.astro";
import BoxContent from "@/components/box/content.astro";
import { buttonVariants } from "@/lib/utils";
---
<Layout
title="Contact Me"
description="Id be glad to connect and discuss how we might work together. Whether you have a project in mind, a question, or just want to say hello, feel free to reach out."
>
<Box>
<BoxContent>
<form id="contact-form" class="space-y-6">
<!-- Access Key for Web3Forms -->
<input type="hidden" name="access_key" value="13de7619-2b61-4a08-ac80-234cb27882dd">
<!-- Customize Web3Forms Settings -->
<input type="hidden" name="subject" value="Portfolio Inquiry - New Contact Message">
<input type="hidden" name="from_name" value="Pangestu Portfolio">
<!-- Botspam protection -->
<input type="checkbox" name="botcheck" class="hidden" style="display: none;">
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<label for="name" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
placeholder="Your name"
required
autocomplete="off"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<p class="text-xs text-destructive hidden" id="name-error">Please enter your name.</p>
</div>
<div class="space-y-2">
<label for="phone" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Phone Number <span class="text-red-500">*</span>
</label>
<input
type="tel"
id="phone"
name="phone"
placeholder="+62 821 .... ...."
autocomplete="off"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<p class="text-xs text-destructive hidden" id="phone-error">Please enter a valid phone number (min 10 digits).</p>
</div>
</div>
<div class="space-y-2">
<label for="email" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Email <span class="text-red-500">*</span>
</label>
<input
type="email"
id="email"
name="email"
placeholder="johndoe@example.com"
required
autocomplete="off"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<p class="text-xs text-destructive hidden" id="email-error">Please enter a valid email address.</p>
</div>
<div class="space-y-2">
<label for="message" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Message <span class="text-red-500">*</span>
</label>
<textarea
id="message"
name="message"
rows="5"
placeholder="Tell me about your project..."
required
autocomplete="off"
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
></textarea>
<p class="text-xs text-destructive hidden" id="message-error">Please enter a message.</p>
</div>
<button type="submit" id="submit-btn" class={buttonVariants({ className: "w-full sm:w-auto" })}>
Send Message
</button>
<div id="result" class="mt-4 text-center text-sm font-medium hidden"></div>
</form>
</BoxContent>
</Box>
<div class="mt-12 text-center text-sm text-muted-foreground">
<p>Prefer chat via WhatsApp?</p>
<a href="https://wa.me/6282121495806?text=Hello%20I%20am%20interested%20in%20your%20services"
class="text-primary hover:underline">
Chat on WhatsApp
</a>
</div>
</Layout>
<script is:inline>
const form = document.getElementById('contact-form');
const result = document.getElementById('result');
const submitBtn = document.getElementById('submit-btn');
const nameInput = document.getElementById('name');
const phoneInput = document.getElementById('phone');
const emailInput = document.getElementById('email');
const messageInput = document.getElementById('message');
const nameError = document.getElementById('name-error');
const phoneError = document.getElementById('phone-error');
const emailError = document.getElementById('email-error');
const messageError = document.getElementById('message-error');
// Regex patterns
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRegex = /^\+?[\d\s-]{10,}$/; // Allows +, digits, spaces, hyphens, min 10 chars
function showError(input, errorElement, show) {
if (show) {
errorElement.classList.remove('hidden');
input.classList.add('border-destructive');
input.classList.remove('border-input');
} else {
errorElement.classList.add('hidden');
input.classList.remove('border-destructive');
input.classList.add('border-input');
}
}
function validate() {
let isValid = true;
// Name Validation
if (!nameInput.value.trim()) {
showError(nameInput, nameError, true);
isValid = false;
} else {
showError(nameInput, nameError, false);
}
// Email Validation
if (!emailRegex.test(emailInput.value.trim())) {
showError(emailInput, emailError, true);
isValid = false;
} else {
showError(emailInput, emailError, false);
}
// Phone Validation (Optional but basic format if entered)
if (phoneInput.value.trim() && !phoneRegex.test(phoneInput.value.trim())) {
showError(phoneInput, phoneError, true);
isValid = false;
} else {
showError(phoneInput, phoneError, false);
}
// Message Validation
if (!messageInput.value.trim()) {
showError(messageInput, messageError, true);
isValid = false;
} else {
showError(messageInput, messageError, false);
}
return isValid;
}
form.addEventListener('submit', function(e) {
e.preventDefault();
if (!validate()) {
return;
}
const formData = new FormData(form);
const object = Object.fromEntries(formData);
const json = JSON.stringify(object);
result.innerHTML = "Sending...";
result.classList.remove('hidden', 'text-green-500', 'text-red-500');
submitBtn.disabled = true;
submitBtn.classList.add('opacity-50', 'cursor-not-allowed');
fetch('https://api.web3forms.com/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: json
})
.then(async (response) => {
let json = await response.json();
if (response.status == 200) {
result.innerHTML = "Message sent successfully! I'll get back to you soon.";
result.classList.add('text-green-500');
form.reset();
} else {
console.log(response);
result.innerHTML = json.message || "Something went wrong. Please try again later.";
result.classList.add('text-red-500');
}
})
.catch(error => {
console.log(error);
result.innerHTML = "Something went wrong. Please try again later.";
result.classList.add('text-red-500');
})
.finally(() => {
form.reset();
submitBtn.disabled = false;
submitBtn.classList.remove('opacity-50', 'cursor-not-allowed');
setTimeout(() => {
result.style.display = "none";
result.classList.add('hidden'); // Ensure hidden class is re-added
result.style.display = ""; // Clear inline style
}, 5000);
});
});
</script>

31
src/pages/index.astro Normal file
View File

@ -0,0 +1,31 @@
---
import Layout from "@/layouts/layout.astro";
import Navbar from "@/components/navbar/index.astro";
import Footer from "@/components/footer.astro";
import Pattern from "@/components/ui/pattern.astro";
import Profile from "@/components/profile.astro";
import About from "@/components/sections/about.astro";
import TechStack from "@/components/tech-stack.astro";
import Experience from "@/components/sections/experience/experience.astro";
---
<Layout title="Yoga Pangestu">
<Navbar />
<main
aria-label="Main content"
class="mx-auto min-h-[calc(100vh-120px)] max-w-3xl"
>
<Pattern className="h-14" />
<Profile />
<Pattern />
<About />
<Pattern />
<TechStack />
<Pattern />
<Experience />
<Pattern />
</main>
<Footer />
</Layout>

View File

@ -0,0 +1,35 @@
---
import { type CollectionEntry, getCollection } from "astro:content";
import ProjectLayout from "@/layouts/project-layout.astro";
import { render } from "astro:content";
export interface Props {
project: CollectionEntry<"projects">;
}
export async function getStaticPaths() {
const projects = await getCollection("projects");
const project = projects.map(project => ({
params: { projectId: project.id },
props: { project },
}));
return project;
}
const { project } = Astro.props;
const rendered = await render(project);
---
<ProjectLayout
title={project.data.name}
technologies={project.data.technologies}
sourceCode={project.data.sourceCode}
preview={project.data.preview}
image={project.data.image}
description={project.data.description}
>
<rendered.Content />
</ProjectLayout>

View File

@ -0,0 +1,44 @@
---
import Pattern from "@/components/ui/pattern.astro";
import PageLayout from "@/layouts/page-layout.astro";
import Box from "@/components/box/index.astro";
import BoxHeader from "@/components/box/header.astro";
import BoxTitle from "@/components/box/title.astro";
import { getCollection } from "astro:content";
import ProjectItem from "@/components/sections/projects/project-item.astro";
const projects = await getCollection("projects");
const coreProjects = projects.filter(project => project.data.type === "core");
const sideProjects = projects.filter(project => project.data.type === "side");
---
<PageLayout
title="Selected Work"
description="A curated showcase of my applications, technical experiments, and production projects."
>
<Pattern />
<Box>
<BoxHeader>
<BoxTitle> Key Project </BoxTitle>
</BoxHeader>
{coreProjects.map(item => <ProjectItem project={item} />)}
</Box>
{
sideProjects.length > 0 && (
<Pattern />
<Box>
<BoxHeader>
<BoxTitle>Side Hustles</BoxTitle>
</BoxHeader>
{sideProjects.map(item => <ProjectItem project={item} />)}
</Box>
)
}
<Pattern />
</PageLayout>

174
src/styles/global.css Normal file
View File

@ -0,0 +1,174 @@
@import "tailwindcss";
@plugin '@tailwindcss/typography';
@custom-variant dark (&:where(.dark, .dark *));
@import "./mdx.css";
@theme {
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-hover: hsl(var(--ring));
--color-radius: var(--radius);
/* Chart Colors */
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
--animate-accordion-down: accordion-down 0.3s ease-out;
--animate-accordion-up: accordion-up 0.3s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 0 0% 3%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 19%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
* {
@apply border-dashed border-border;
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
html {
@apply scroll-smooth;
font-family: Geist Sans;
}
body {
@apply overscroll-none bg-background text-foreground;
font-synthesis-weight: none;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3 {
font-family: Roboto Condensed;
}
pre {
font-family: JetBrains Mono;
border: 0;
}
/* Custom scrollbar styling */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 5px;
}
@utility screen-line-before {
@apply relative before:absolute before:top-0 before:-left-[100vw] before:h-0 before:w-[200vw] before:border-t before:border-dashed before:border-border;
}
@utility screen-line-after {
@apply relative after:absolute after:bottom-0 after:-left-[100vw] after:h-0 after:w-[200vw] after:border-b after:border-dashed after:border-border;
}
.copy-code {
@apply absolute top-2 right-2 cursor-pointer rounded-md border border-transparent p-2 text-xs font-bold duration-150 hover:bg-primary hover:text-primary-foreground;
}
.copy-code svg {
@apply size-4;
}

38
src/styles/mdx.css Normal file
View File

@ -0,0 +1,38 @@
@layer base {
.prose {
@apply prose-headings:!mb-2 prose-headings:scroll-mt-20 prose-headings:!text-foreground prose-p:!text-sm prose-p:leading-8 prose-p:!text-foreground lg:prose-p:!text-base dark:prose-p:!text-foreground/80 prose-a:!text-foreground prose-a:underline-offset-4 prose-blockquote:!border-l-accent prose-blockquote:opacity-80 prose-figcaption:!text-foreground prose-figcaption:opacity-70 prose-strong:!text-foreground prose-code:rounded prose-code:bg-muted/75 prose-code:p-1 prose-code:!text-foreground prose-code:before:!content-none prose-code:after:!content-none prose-pre:!max-h-[512px] prose-ol:!text-foreground prose-ul:overflow-x-clip prose-ul:!text-foreground prose-li:!text-sm prose-li:!text-foreground prose-li:marker:!text-foreground/80 lg:prose-li:!text-base dark:prose-li:!text-foreground/80 prose-table:text-foreground prose-th:border prose-th:border-border prose-td:border prose-td:border-border prose-img:!my-2 prose-img:!rounded-lg prose-video:!rounded-lg prose-hr:!border-border;
}
.prose a {
@apply break-words;
}
.prose thead th:first-child,
tbody td:first-child,
tfoot td:first-child {
padding-inline-start: 0.5714286em !important;
}
.prose h2#table-of-contents {
@apply mb-2;
}
.prose details {
@apply inline-block cursor-pointer text-foreground select-none;
}
.prose summary {
@apply focus-visible:no-underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent focus-visible:outline-dashed;
}
.prose h2#table-of-contents + p {
@apply hidden;
}
/* ===== Code Blocks & Syntax Highlighting ===== */
pre:has(code) {
@apply border border-border;
}
code,
blockquote {
word-wrap: break-word;
}
pre > code {
white-space: pre;
}
}

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": {
"strictNullChecks": true,
"allowJs": true,
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}
}