source-code/
portofolio-neo-gruv
Public
codeCodeinfoIssues 0call_splitPull Requestsplay_circleActions
portofolio-neo-gruv/src/app/dashboard/experiences/page.tsx
typescript177 lines7.5 KB
"use client";

import React, { useState, useEffect } from 'react';
import { Title } from '../../../ui/Title';
import { Button } from '../../../ui/Button';
import { Input } from '../../../ui/Input';
import {
  getExperiences,
  createExperience,
  updateExperience,
  deleteExperience,
  ExperienceEntity
} from '../../../services/dashboard';

export default function ExperiencesDashboard() {
  const [experiences, setExperiences] = useState<ExperienceEntity[]>([]);
  const [loading, setLoading] = useState(true);
  const [editingId, setEditingId] = useState<number | null>(null);
  const [formData, setFormData] = useState({
    role: '',
    company: '',
    period: '',
    description: '',
    skills: '',
    order: '0',
  });

  useEffect(() => {
    fetchExperiences();
  }, []);

  const fetchExperiences = async () => {
    try {
      const data = await getExperiences();
      setExperiences(data);
    } catch (e) {
      console.error(e);
    } finally {
      setLoading(false);
    }
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const isEditing = editingId !== null;

    const payload = {
      ...formData,
      order: parseInt(formData.order, 10) || 0,
      skills: formData.skills
        ? formData.skills.split(',').map(s => s.trim()).filter(Boolean)
        : [],
    };

    try {
      if (isEditing) {
        await updateExperience(editingId, payload);
      } else {
        await createExperience(payload);
      }
      setFormData({ role: '', company: '', period: '', description: '', skills: '', order: '0' });
      setEditingId(null);
      fetchExperiences();
    } catch (err: any) {
      alert(err.message || 'Error saving experience');
    }
  };

  const handleEdit = (exp: ExperienceEntity) => {
    setEditingId(exp.id);
    setFormData({
      role: exp.role,
      company: exp.company,
      period: exp.period,
      description: exp.description,
      skills: exp.skills ? exp.skills.map(s => s.name).join(', ') : '',
      order: String(exp.order || 0),
    });
    window.scrollTo({ top: 0, behavior: 'smooth' });
  };

  const handleDelete = async (id: number) => {
    if (!confirm('Are you sure you want to delete this experience?')) return;
    try {
      await deleteExperience(id);
      fetchExperiences();
    } catch (err: any) {
      alert(err.message || 'Error deleting experience');
    }
  };

  if (loading) return <div className="p-8 font-label-bold uppercase animate-pulse">Loading Experiences...</div>;

  return (
    <div className="max-w-5xl mx-auto flex flex-col gap-8">
      <div className="bg-surface p-6 md:p-10 neo-border-heavy neo-shadow-sm">
        <Title 
          as="h3" 
          prefix="" 
          highlight={editingId ? "EDIT EXPERIENCE" : "NEW EXPERIENCE"} 
          highlightColorClass="bg-theme-yellow text-on-surface" 
          highlightRotateClass="-rotate-1"
          className="mb-8 uppercase tracking-tighter text-[28px] md:text-[36px] font-display-2xl leading-none" 
        />
        <form onSubmit={handleSubmit} className="flex flex-col gap-4">
          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
            <div>
              <label className="font-label-bold text-sm uppercase opacity-80 mb-2 block">Role</label>
              <Input className="w-full" value={formData.role} onChange={e => setFormData({...formData, role: e.target.value})} required />
            </div>
            <div>
              <label className="font-label-bold text-sm uppercase opacity-80 mb-2 block">Company</label>
              <Input className="w-full" value={formData.company} onChange={e => setFormData({...formData, company: e.target.value})} required />
            </div>
            <div>
              <label className="font-label-bold text-sm uppercase opacity-80 mb-2 block">Period (e.g. 2021 - Present)</label>
              <Input className="w-full" value={formData.period} onChange={e => setFormData({...formData, period: e.target.value})} required />
            </div>
            <div>
              <label className="font-label-bold text-sm uppercase opacity-80 mb-2 block">Order</label>
              <Input type="number" className="w-full" value={formData.order} onChange={e => setFormData({...formData, order: e.target.value})} required />
            </div>
            <div className="md:col-span-2">
              <label className="font-label-bold text-sm uppercase opacity-80 mb-2 block">Skills / Tags (Comma-separated, e.g. HTML5, SASS, JavaScript)</label>
              <Input className="w-full" value={formData.skills} onChange={e => setFormData({...formData, skills: e.target.value})} placeholder="HTML5, SASS, JavaScript" />
            </div>
            <div className="md:col-span-2">
              <label className="font-label-bold text-sm uppercase opacity-80 mb-2 block">Description</label>
              <textarea 
                className="w-full p-3 font-mono text-sm neo-border bg-surface focus:outline-none focus:ring-4 focus:ring-theme-yellow min-h-[100px]"
                value={formData.description}
                onChange={e => setFormData({...formData, description: e.target.value})}
                required
              />
            </div>
          </div>
          <div className="flex gap-4 mt-4">
            <Button type="submit" className="bg-theme-blue text-surface py-4 neo-border hover:-translate-y-1 hover:shadow-[4px_4px_0px_0px_#1e1b19] transition-all px-8">
              {editingId ? 'UPDATE EXPERIENCE' : 'ADD EXPERIENCE'}
            </Button>
            {editingId && (
              <Button type="button" onClick={() => { setEditingId(null); setFormData({ role: '', company: '', period: '', description: '', skills: '', order: '0' }); }} className="bg-theme-grey text-on-surface py-4 neo-border hover:-translate-y-1 hover:shadow-[4px_4px_0px_0px_#1e1b19] transition-all px-8">
                CANCEL
              </Button>
            )}
          </div>
        </form>
      </div>

      <div className="flex flex-col gap-4">
        {experiences.map(exp => (
          <div key={exp.id} className="bg-surface p-6 neo-border flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
            <div className="flex-1">
              <h4 className="font-display-2xl text-2xl uppercase tracking-tighter">{exp.role}</h4>
              <p className="font-label-bold opacity-60 text-sm mb-2">{exp.company} • {exp.period} • Order: {exp.order}</p>
              <p className="text-sm font-mono opacity-80 line-clamp-2">{exp.description}</p>
              {exp.skills && exp.skills.length > 0 && (
                <div className="flex flex-wrap gap-1.5 mt-3">
                  {exp.skills.map((s) => (
                    <span key={s.id} className="font-mono text-xs uppercase px-2 py-0.5 bg-theme-yellow/20 neo-border border-[2px]">
                      {s.name}
                    </span>
                  ))}
                </div>
              )}
            </div>
            <div className="flex gap-3 w-full md:w-auto md:flex-col lg:flex-row">
              <button onClick={() => handleEdit(exp)} className="font-label-bold text-xs uppercase bg-theme-yellow px-6 py-3 neo-border flex-1 hover:bg-on-surface hover:text-surface transition-colors">Edit</button>
              <button onClick={() => handleDelete(exp.id)} className="font-label-bold text-xs uppercase bg-theme-red text-surface px-6 py-3 neo-border flex-1 hover:bg-on-surface hover:text-theme-red transition-colors">Delete</button>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

About

Custom portfolio frontend designed using retro Neo-Brutalist styling. Features server-rendered pages, persistent codebase layout, interactive file explorer tree, and Shiki code syntax highlighting.

TypeScriptNext.jsReact 19Tailwind CSSShiki

Contributors

1