diff --git a/.gitignore b/.gitignore index c264e05..2491303 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ node_modules /app/assets/builds/* !/app/assets/builds/.keep node_modules/ +yarn.lock diff --git a/App.css b/App.css new file mode 100644 index 0000000..e08a42f --- /dev/null +++ b/App.css @@ -0,0 +1,335 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 0.5rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #cc0000aa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #336791aa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +/* Terminal and code styling */ +.terminal-output::-webkit-scrollbar { + width: 4px; +} + +.terminal-output::-webkit-scrollbar-track { + background: #1a1a1a; +} + +.terminal-output::-webkit-scrollbar-thumb { + background: #444; + border-radius: 2px; +} + +.ascii-art { + font-family: monospace; + white-space: pre; + font-size: 0.7rem; + line-height: 1; +} + +/* Matrix animation - kept for reference but not used in new design */ +.matrix-container { + display: none; +} + +/* Analog noise overlay */ +.noise-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.1'/%3E%3C/svg%3E"); + pointer-events: none; + opacity: 0.06; + z-index: 10; +} + +/* Audio waveform divider */ +.waveform-divider { + height: 20px; + background: url("data:image/svg+xml,%3Csvg width='100%25' height='20' viewBox='0 0 1200 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 10 Q 25 5, 50 10 T 100 10 T 150 10 T 200 10 T 250 10 T 300 10 T 350 10 T 400 10 T 450 10 T 500 10 T 550 10 T 600 10 T 650 10 T 700 10 T 750 10 T 800 10 T 850 10 T 900 10 T 950 10 T 1000 10 T 1050 10 T 1100 10 T 1150 10 T 1200 10' stroke='%23555' fill='none' stroke-width='1'/%3E%3C/svg%3E"); + margin: 1.5rem 0; + opacity: 0.5; +} + +/* Ruby on Rails and Postgres inspired colors */ +.rails-red { + color: #CC0000 !important; +} + +.postgres-blue { + color: #336791 !important; +} + +.card-rails { + border-color: rgba(204, 0, 0, 0.3) !important; +} + +.card-postgres { + border-color: rgba(51, 103, 145, 0.3) !important; +} + +/* Mechanical key styling */ +.mech-key { + font-family: 'JetBrains Mono', monospace; + padding: 3px 8px; + background: #222; + border: 1px solid #444; + border-radius: 3px; + box-shadow: 0 2px 0 #111; + color: #eee; + display: inline-block; + margin: 0 2px; +} + +/* Various animation styles */ +/* Analog VU meter animation */ +@keyframes vu-meter { + 0% { width: 20%; } + 20% { width: 60%; } + 40% { width: 40%; } + 60% { width: 80%; } + 80% { width: 30%; } + 100% { width: 50%; } +} + +.vu-meter { + height: 4px; + background: linear-gradient(90deg, #39ff14, #f3d611, #f33611); + animation: vu-meter 4s ease-in-out infinite; + border-radius: 2px; + margin: 8px 0; +} + +/* Loading bar animation */ +@keyframes loading-bar { + 0% { width: 0%; background-position: 0% 50%; } + 50% { width: 100%; background-position: 100% 50%; } + 100% { width: 0%; background-position: 0% 50%; } +} + +.loading-bar { + height: 3px; + background: linear-gradient(90deg, #39ff14, #bc13fe, #336791, #CC0000); + background-size: 300% 300%; + animation: loading-bar 8s ease infinite; + border-radius: 2px; + margin: 8px 0; +} + +/* Sine wave animation */ +@keyframes sine-wave { + 0% { + clip-path: path('M0,10 Q5,5 10,10 T20,10 T30,10 T40,10 T50,10 T60,10 T70,10 T80,10 T90,10 T100,10'); + } + 25% { + clip-path: path('M0,10 Q5,15 10,10 T20,10 T30,10 T40,10 T50,10 T60,10 T70,10 T80,10 T90,10 T100,10'); + } + 50% { + clip-path: path('M0,10 Q5,10 10,5 T20,10 T30,5 T40,10 T50,15 T60,10 T70,5 T80,10 T90,15 T100,10'); + } + 75% { + clip-path: path('M0,10 Q5,5 10,15 T20,5 T30,15 T40,5 T50,15 T60,5 T70,15 T80,5 T90,15 T100,10'); + } + 100% { + clip-path: path('M0,10 Q5,5 10,10 T20,10 T30,10 T40,10 T50,10 T60,10 T70,10 T80,10 T90,10 T100,10'); + } +} + +.sine-wave { + background: linear-gradient(90deg, #39ff14, #cc0000); + animation: sine-wave 4s ease-in-out infinite; + border-radius: 2px; + margin: 8px 0; +} + +/* Pulse dot animation */ +@keyframes pulse-dot { + 0% { transform: scale(0.8); opacity: 0.5; } + 50% { transform: scale(1.2); opacity: 1; } + 100% { transform: scale(0.8); opacity: 0.5; } +} + +.pulse-dot { + width: 6px; + height: 6px; + background: #39ff14; + border-radius: 50%; + margin: 8px auto; + animation: pulse-dot 2s ease-in-out infinite; +} + +/* Glitchy text effect */ +@keyframes glitch-text { + 0% { text-shadow: -1px -1px 0 rgba(255,0,0,0.3), 1px 1px 0 rgba(0,255,255,0.3); } + 25% { text-shadow: 1px -1px 0 rgba(255,0,0,0.3), -1px 1px 0 rgba(0,255,255,0.3); } + 50% { text-shadow: -1px 1px 0 rgba(255,0,0,0.3), 1px -1px 0 rgba(0,255,255,0.3); } + 75% { text-shadow: 1px 1px 0 rgba(255,0,0,0.3), -1px -1px 0 rgba(0,255,255,0.3); } + 100% { text-shadow: -1px -1px 0 rgba(255,0,0,0.3), 1px 1px 0 rgba(0,255,255,0.3); } +} + +.text-glitch:hover { + animation: glitch-text 0.3s ease-in-out infinite; +} + +/* Server status pulse animation */ +@keyframes server-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 5px currentColor; } + 50% { opacity: 0.6; box-shadow: 0 0 10px currentColor; } +} + +/* Server status indicators */ +.server-status { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + margin-right: 6px; + animation: server-pulse 2s ease-in-out infinite; +} + +.server-online { + background-color: #0f9d58; +} + +.server-issue { + background-color: #f4b400; +} + +.server-offline { + background-color: #db4437; +} + +/* Terminal status indicators that match Tailwind classes */ +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + margin-right: 6px; + animation: server-pulse 2s ease-in-out infinite; +} + +/* Terminal window macOS style */ +.terminal-header { + height: 24px; + background-color: #2a2a2a; + border-bottom: 1px solid #444; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + display: flex; + align-items: center; + padding: 0 8px; +} + +.terminal-dot { + width: 12px; + height: 12px; + border-radius: 50%; + margin-right: 6px; +} + +.terminal-dot-red { + background-color: #ff5f56; +} + +.terminal-dot-yellow { + background-color: #ffbd2e; +} + +.terminal-dot-green { + background-color: #27c93f; +} + +.terminal-title { + color: #aaa; + font-size: 12px; + margin-left: 4px; + font-family: 'JetBrains Mono', monospace; +} + +/* MacOS window styling for bento boxes */ +.macos-window { + border-radius: 6px; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3); + overflow: hidden; + backdrop-filter: blur(8px); +} + +.macos-header { + height: 26px; + background: linear-gradient(to bottom, #3a3a3a, #2a2a2a); + border-bottom: 1px solid #444; + display: flex; + align-items: center; + padding: 0 10px; +} + +.macos-buttons { + display: flex; + gap: 6px; +} + +.macos-button { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.macos-button-close { + background-color: #ff5f56; + border: 1px solid #e0443e; +} + +.macos-button-minimize { + background-color: #ffbd2e; + border: 1px solid #dea123; +} + +.macos-button-expand { + background-color: #27c93f; + border: 1px solid #1aab29; +} + +.macos-content { + padding: 10px; + background-color: rgba(30, 30, 30, 0.8); +} + +/* Blink animation (caret) */ +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +.animate-blink { + animation: blink 1s step-end infinite; +} \ No newline at end of file diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..ab5ebf1 --- /dev/null +++ b/App.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import "./App.css"; +import "./index.css"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import Navigation from "./components/Navigation"; +import Index from "./pages/Index"; +import Blog from "./pages/Blog"; +import Work from "./pages/Work"; +import Contact from "./pages/Contact"; +import Fun from "./pages/Fun"; +import Games from "./pages/Games"; +import NotFound from "./pages/NotFound"; + +// Background texture overlay +const BackgroundTexture = () => ( +
+
+
+
+
+
+); + +// App component with cleaner design +const App = () => ( + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + +); + +export default App; \ No newline at end of file diff --git a/Procfile.dev b/Procfile.dev index da151fe..1f45bf2 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,2 @@ web: bin/rails server -css: bin/rails tailwindcss:watch +vite: npm run dev diff --git a/app/assets/stylesheets/custom_scrollbar.css b/app/assets/stylesheets/custom_scrollbar.css index a0bf30c..f02de52 100644 --- a/app/assets/stylesheets/custom_scrollbar.css +++ b/app/assets/stylesheets/custom_scrollbar.css @@ -1,18 +1,18 @@ -/* Chrome, Edge, and Safari */ +/* Chrome, Edge, and Safari - Terminal Theme */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 4px; + background: rgba(30, 30, 30, 0.8); + border-radius: 2px; background-image: repeating-linear-gradient( to bottom, transparent, transparent 48px, - #888 48px, - #888 52px, + #444 48px, + #444 52px, transparent 52px ); background-position: center; @@ -21,25 +21,25 @@ } ::-webkit-scrollbar-thumb { - background-color: #888; - border-radius: 4px; + background: rgba(155, 135, 245, 0.7); + border-radius: 2px; } ::-webkit-scrollbar-thumb:hover { - background-color: #555; + background: rgba(155, 135, 245, 0.9); } /* Firefox */ * { scrollbar-width: thin; - scrollbar-color: #888 #f1f1f1; + scrollbar-color: rgba(155, 135, 245, 0.7) rgba(30, 30, 30, 0.8); } -/* Custom class for directly applying to specific elements */ +/* Custom class for directly applying to specific elements - Terminal Theme */ .custom-scrollbar { /* Firefox */ scrollbar-width: thin; - scrollbar-color: #888 #f1f1f1; + scrollbar-color: rgba(155, 135, 245, 0.7) rgba(30, 30, 30, 0.8); } .custom-scrollbar::-webkit-scrollbar { @@ -48,14 +48,14 @@ } .custom-scrollbar::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 4px; + background: rgba(30, 30, 30, 0.8); + border-radius: 2px; background-image: repeating-linear-gradient( to bottom, transparent, transparent 48px, - #888 48px, - #888 52px, + #444 48px, + #444 52px, transparent 52px ); background-position: center; @@ -64,12 +64,12 @@ } .custom-scrollbar::-webkit-scrollbar-thumb { - background-color: #888; - border-radius: 4px; + background: rgba(155, 135, 245, 0.7); + border-radius: 2px; } .custom-scrollbar::-webkit-scrollbar-thumb:hover { - background-color: #555; + background: rgba(155, 135, 245, 0.9); } /* Force scrollbar to be visible even when content doesn't overflow */ diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb new file mode 100644 index 0000000..b50cf5f --- /dev/null +++ b/app/controllers/admin_controller.rb @@ -0,0 +1,107 @@ +class AdminController < ApplicationController + layout 'admin' + + def dashboard + @blog_posts_count = BlogPost.count + @game_posts_count = GamePost.count + @work_posts_count = WorkPost.count + end + + def blog_posts + @blog_posts = BlogPost.all.order(created_at: :desc) + end + + def game_posts + @game_posts = GamePost.all.order(created_at: :desc) + end + + def work_posts + @work_posts = WorkPost.all.order(created_at: :desc) + end + + def new_game_post + @game_post = GamePost.new + end + + def create_game_post + @game_post = GamePost.new(game_post_params) + + if @game_post.save + redirect_to admin_game_posts_path, notice: "Game post was successfully created." + else + render :new_game_post + end + end + + def edit_game_post + @game_post = GamePost.find(params[:id]) + end + + def update_game_post + @game_post = GamePost.find(params[:id]) + + if @game_post.update(game_post_params) + redirect_to admin_game_posts_path, notice: "Game post was successfully updated." + else + render :edit_game_post + end + end + + def destroy_game_post + @game_post = GamePost.find(params[:id]) + @game_post.destroy + + redirect_to admin_game_posts_path, notice: "Game post was successfully deleted." + end + + def new_work_post + @work_post = WorkPost.new + end + + def create_work_post + @work_post = WorkPost.new(work_post_params) + + if @work_post.save + redirect_to admin_work_posts_path, notice: "Work post was successfully created." + else + render :new_work_post + end + end + + def edit_work_post + @work_post = WorkPost.find(params[:id]) + end + + def update_work_post + @work_post = WorkPost.find(params[:id]) + + if @work_post.update(work_post_params) + redirect_to admin_work_posts_path, notice: "Work post was successfully updated." + else + render :edit_work_post + end + end + + def destroy_work_post + @work_post = WorkPost.find(params[:id]) + @work_post.destroy + + redirect_to admin_work_posts_path, notice: "Work post was successfully deleted." + end + + private + + def authenticate_admin + # In a real application, you'd want to use a proper authentication system + # This is just a placeholder for demonstration purposes + true + end + + def game_post_params + params.require(:game_post).permit(:title, :description, :image_url, :link, :featured) + end + + def work_post_params + params.require(:work_post).permit(:title, :description, :image_url, :featured, tags: []) + end +end \ No newline at end of file diff --git a/app/controllers/api/blog_posts_controller.rb b/app/controllers/api/blog_posts_controller.rb new file mode 100644 index 0000000..6225472 --- /dev/null +++ b/app/controllers/api/blog_posts_controller.rb @@ -0,0 +1,17 @@ +module Api + class BlogPostsController < ApplicationController + def index + @blog_posts = BlogPost.all.order(created_at: :desc) + render json: @blog_posts + end + + def show + @blog_post = BlogPost.find_by(slug: params[:slug]) + if @blog_post + render json: @blog_post + else + render json: { error: "Blog post not found" }, status: :not_found + end + end + end +end \ No newline at end of file diff --git a/app/controllers/api/game_posts_controller.rb b/app/controllers/api/game_posts_controller.rb new file mode 100644 index 0000000..0d6622e --- /dev/null +++ b/app/controllers/api/game_posts_controller.rb @@ -0,0 +1,17 @@ +module Api + class GamePostsController < ApplicationController + def index + @game_posts = GamePost.all.order(created_at: :desc) + render json: @game_posts + end + + def show + @game_post = GamePost.find_by(slug: params[:slug]) + if @game_post + render json: @game_post + else + render json: { error: "Game post not found" }, status: :not_found + end + end + end +end \ No newline at end of file diff --git a/app/controllers/api/work_posts_controller.rb b/app/controllers/api/work_posts_controller.rb new file mode 100644 index 0000000..d77a877 --- /dev/null +++ b/app/controllers/api/work_posts_controller.rb @@ -0,0 +1,17 @@ +module Api + class WorkPostsController < ApplicationController + def index + @work_posts = WorkPost.all.order(created_at: :desc) + render json: @work_posts + end + + def show + @work_post = WorkPost.find_by(slug: params[:slug]) + if @work_post + render json: @work_post + else + render json: { error: "Work post not found" }, status: :not_found + end + end + end +end \ No newline at end of file diff --git a/app/controllers/frontend_controller.rb b/app/controllers/frontend_controller.rb new file mode 100644 index 0000000..5e78bb5 --- /dev/null +++ b/app/controllers/frontend_controller.rb @@ -0,0 +1,12 @@ +class FrontendController < ApplicationController + # Disable layout for this controller since we're rendering a React app + layout false + + # Turn off content security policy for development to allow Vite HMR + content_security_policy false if Rails.env.development? + + def show + # Render the view that includes our React application + render :show + end +end \ No newline at end of file diff --git a/app/controllers/games_controller.rb b/app/controllers/games_controller.rb new file mode 100644 index 0000000..14c1f79 --- /dev/null +++ b/app/controllers/games_controller.rb @@ -0,0 +1,18 @@ +class GamesController < ApplicationController + def index + @game_posts = GamePost.all.order(created_at: :desc) + respond_to do |format| + format.html + format.json { render json: @game_posts } + end + end + + def show + @game_post = GamePost.find_by(slug: params[:slug]) + + respond_to do |format| + format.html + format.json { render json: @game_post } + end + end +end \ No newline at end of file diff --git a/app/controllers/works_controller.rb b/app/controllers/works_controller.rb new file mode 100644 index 0000000..8bad3a5 --- /dev/null +++ b/app/controllers/works_controller.rb @@ -0,0 +1,18 @@ +class WorksController < ApplicationController + def index + @work_posts = WorkPost.all.order(created_at: :desc) + respond_to do |format| + format.html + format.json { render json: @work_posts } + end + end + + def show + @work_post = WorkPost.find_by(slug: params[:slug]) + + respond_to do |format| + format.html + format.json { render json: @work_post } + end + end +end \ No newline at end of file diff --git a/app/helpers/frontend_helper.rb b/app/helpers/frontend_helper.rb new file mode 100644 index 0000000..6035a51 --- /dev/null +++ b/app/helpers/frontend_helper.rb @@ -0,0 +1,2 @@ +module FrontendHelper +end diff --git a/app/javascript/channels/application_cable/connection.rb b/app/javascript/channels/application_cable/connection.rb new file mode 100644 index 0000000..d32f603 --- /dev/null +++ b/app/javascript/channels/application_cable/connection.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end \ No newline at end of file diff --git a/app/models/game_post.rb b/app/models/game_post.rb new file mode 100644 index 0000000..2fb73e6 --- /dev/null +++ b/app/models/game_post.rb @@ -0,0 +1,15 @@ +class GamePost < ApplicationRecord + validates :title, presence: true + validates :description, presence: true + validates :slug, presence: true, uniqueness: true + validates :image_url, presence: true + validates :link, presence: true + + before_validation :generate_slug, on: :create + + private + + def generate_slug + self.slug ||= title.parameterize if title.present? + end +end \ No newline at end of file diff --git a/app/models/work_post.rb b/app/models/work_post.rb new file mode 100644 index 0000000..cf74332 --- /dev/null +++ b/app/models/work_post.rb @@ -0,0 +1,15 @@ +class WorkPost < ApplicationRecord + validates :title, presence: true + validates :description, presence: true + validates :slug, presence: true, uniqueness: true + validates :image_url, presence: true + validates :tags, presence: true + + before_validation :generate_slug, on: :create + + private + + def generate_slug + self.slug ||= title.parameterize if title.present? + end +end \ No newline at end of file diff --git a/app/views/admin/blog_posts.html.erb b/app/views/admin/blog_posts.html.erb new file mode 100644 index 0000000..b3c62f5 --- /dev/null +++ b/app/views/admin/blog_posts.html.erb @@ -0,0 +1,36 @@ +
+
+

Blog Posts

+ Add New Post +
+ + <% if @blog_posts.any? %> + + + + + + + + + + + <% @blog_posts.each do |blog_post| %> + + + + + + + <% end %> + +
TitleSlugCreatedActions
<%= blog_post.title %><%= blog_post.slug %><%= blog_post.created_at.strftime("%b %d, %Y") %> + Edit + +
+ <% else %> +

No blog posts found.

+ <% end %> + +

Note: Blog post management features are coming soon.

+
\ No newline at end of file diff --git a/app/views/admin/dashboard.html.erb b/app/views/admin/dashboard.html.erb new file mode 100644 index 0000000..8968f70 --- /dev/null +++ b/app/views/admin/dashboard.html.erb @@ -0,0 +1,28 @@ +
+

Dashboard

+ +
+
+

Blog Posts

+

<%= @blog_posts_count %>

+ <%= link_to "Manage", admin_blog_posts_path, class: "btn btn-primary mt-4" %> +
+ +
+

Game Posts

+

<%= @game_posts_count %>

+ <%= link_to "Manage", admin_game_posts_path, class: "btn btn-primary mt-4" %> +
+ +
+

Work Posts

+

<%= @work_posts_count %>

+ <%= link_to "Manage", admin_work_posts_path, class: "btn btn-primary mt-4" %> +
+
+
+ +
+

Recent Activity

+

No recent activity to display.

+
\ No newline at end of file diff --git a/app/views/admin/edit_game_post.html.erb b/app/views/admin/edit_game_post.html.erb new file mode 100644 index 0000000..4f7521d --- /dev/null +++ b/app/views/admin/edit_game_post.html.erb @@ -0,0 +1,46 @@ +
+

Edit Game Post

+ + <%= form_with(model: @game_post, url: admin_update_game_post_path(@game_post), method: :patch, local: true) do |form| %> + <% if @game_post.errors.any? %> +
+

<%= pluralize(@game_post.errors.count, "error") %> prohibited this game post from being saved:

+ +
+ <% end %> + +
+ <%= form.label :title, class: "form-label" %> + <%= form.text_field :title, class: "form-control" %> +
+ +
+ <%= form.label :description, class: "form-label" %> + <%= form.text_area :description, rows: 5, class: "form-control" %> +
+ +
+ <%= form.label :image_url, class: "form-label" %> + <%= form.text_field :image_url, class: "form-control" %> +
+ +
+ <%= form.label :link, class: "form-label" %> + <%= form.text_field :link, class: "form-control" %> +
+ +
+ <%= form.check_box :featured, class: "checkbox" %> + <%= form.label :featured, class: "form-label mb-0" %> +
+ +
+ <%= form.submit "Update Game Post", class: "btn btn-primary mr-2" %> + <%= link_to "Cancel", admin_game_posts_path, class: "btn bg-gray-200 text-gray-800 hover:bg-gray-300" %> +
+ <% end %> +
\ No newline at end of file diff --git a/app/views/admin/edit_work_post.html.erb b/app/views/admin/edit_work_post.html.erb new file mode 100644 index 0000000..55dcee6 --- /dev/null +++ b/app/views/admin/edit_work_post.html.erb @@ -0,0 +1,46 @@ +
+

Edit Work Post

+ + <%= form_with(model: @work_post, url: admin_update_work_post_path(@work_post), method: :patch, local: true) do |form| %> + <% if @work_post.errors.any? %> +
+

<%= pluralize(@work_post.errors.count, "error") %> prohibited this work post from being saved:

+ +
+ <% end %> + +
+ <%= form.label :title, class: "form-label" %> + <%= form.text_field :title, class: "form-control" %> +
+ +
+ <%= form.label :description, class: "form-label" %> + <%= form.text_area :description, rows: 5, class: "form-control" %> +
+ +
+ <%= form.label :image_url, class: "form-label" %> + <%= form.text_field :image_url, class: "form-control" %> +
+ +
+ <%= form.label :tags, "Tags (comma separated)", class: "form-label" %> + <%= form.text_field :tags, value: @work_post.tags&.join(", "), class: "form-control" %> +
+ +
+ <%= form.check_box :featured, class: "checkbox" %> + <%= form.label :featured, class: "form-label mb-0" %> +
+ +
+ <%= form.submit "Update Work Post", class: "btn btn-primary mr-2" %> + <%= link_to "Cancel", admin_work_posts_path, class: "btn bg-gray-200 text-gray-800 hover:bg-gray-300" %> +
+ <% end %> +
\ No newline at end of file diff --git a/app/views/admin/game_posts.html.erb b/app/views/admin/game_posts.html.erb new file mode 100644 index 0000000..d6fef45 --- /dev/null +++ b/app/views/admin/game_posts.html.erb @@ -0,0 +1,36 @@ +
+
+

Game Posts

+ <%= link_to "Add New Game", admin_new_game_post_path, class: "btn btn-primary" %> +
+ + <% if @game_posts.any? %> + + + + + + + + + + + + <% @game_posts.each do |game_post| %> + + + + + + + + <% end %> + +
TitleDescriptionFeaturedCreatedActions
<%= game_post.title %><%= truncate(game_post.description, length: 50) %><%= game_post.featured ? "Yes" : "No" %><%= game_post.created_at.strftime("%b %d, %Y") %> + <%= link_to "Edit", admin_edit_game_post_path(game_post), class: "text-blue-600 hover:underline mr-2" %> + <%= button_to "Delete", admin_destroy_game_post_path(game_post), method: :delete, data: { confirm: "Are you sure?" }, class: "text-red-600 hover:underline bg-transparent border-0 p-0 cursor-pointer" %> +
+ <% else %> +

No game posts found. <%= link_to "Create your first game post", admin_new_game_post_path, class: "text-blue-600 hover:underline" %>.

+ <% end %> +
\ No newline at end of file diff --git a/app/views/admin/new_game_post.html.erb b/app/views/admin/new_game_post.html.erb new file mode 100644 index 0000000..8aae879 --- /dev/null +++ b/app/views/admin/new_game_post.html.erb @@ -0,0 +1,46 @@ +
+

New Game Post

+ + <%= form_with(model: @game_post, url: admin_create_game_post_path, local: true) do |form| %> + <% if @game_post.errors.any? %> +
+

<%= pluralize(@game_post.errors.count, "error") %> prohibited this game post from being saved:

+ +
+ <% end %> + +
+ <%= form.label :title, class: "form-label" %> + <%= form.text_field :title, class: "form-control" %> +
+ +
+ <%= form.label :description, class: "form-label" %> + <%= form.text_area :description, rows: 5, class: "form-control" %> +
+ +
+ <%= form.label :image_url, class: "form-label" %> + <%= form.text_field :image_url, class: "form-control" %> +
+ +
+ <%= form.label :link, class: "form-label" %> + <%= form.text_field :link, class: "form-control" %> +
+ +
+ <%= form.check_box :featured, class: "checkbox" %> + <%= form.label :featured, class: "form-label mb-0" %> +
+ +
+ <%= form.submit "Create Game Post", class: "btn btn-primary mr-2" %> + <%= link_to "Cancel", admin_game_posts_path, class: "btn bg-gray-200 text-gray-800 hover:bg-gray-300" %> +
+ <% end %> +
\ No newline at end of file diff --git a/app/views/admin/new_work_post.html.erb b/app/views/admin/new_work_post.html.erb new file mode 100644 index 0000000..c6473df --- /dev/null +++ b/app/views/admin/new_work_post.html.erb @@ -0,0 +1,46 @@ +
+

New Work Post

+ + <%= form_with(model: @work_post, url: admin_create_work_post_path, local: true) do |form| %> + <% if @work_post.errors.any? %> +
+

<%= pluralize(@work_post.errors.count, "error") %> prohibited this work post from being saved:

+ +
+ <% end %> + +
+ <%= form.label :title, class: "form-label" %> + <%= form.text_field :title, class: "form-control" %> +
+ +
+ <%= form.label :description, class: "form-label" %> + <%= form.text_area :description, rows: 5, class: "form-control" %> +
+ +
+ <%= form.label :image_url, class: "form-label" %> + <%= form.text_field :image_url, class: "form-control" %> +
+ +
+ <%= form.label :tags, "Tags (comma separated)", class: "form-label" %> + <%= form.text_field :tags, value: @work_post.tags&.join(", "), class: "form-control" %> +
+ +
+ <%= form.check_box :featured, class: "checkbox" %> + <%= form.label :featured, class: "form-label mb-0" %> +
+ +
+ <%= form.submit "Create Work Post", class: "btn btn-primary mr-2" %> + <%= link_to "Cancel", admin_work_posts_path, class: "btn bg-gray-200 text-gray-800 hover:bg-gray-300" %> +
+ <% end %> +
\ No newline at end of file diff --git a/app/views/admin/work_posts.html.erb b/app/views/admin/work_posts.html.erb new file mode 100644 index 0000000..b0d0962 --- /dev/null +++ b/app/views/admin/work_posts.html.erb @@ -0,0 +1,38 @@ +
+
+

Work Posts

+ <%= link_to "Add New Work", admin_new_work_post_path, class: "btn btn-primary" %> +
+ + <% if @work_posts.any? %> + + + + + + + + + + + + + <% @work_posts.each do |work_post| %> + + + + + + + + + <% end %> + +
TitleDescriptionTagsFeaturedCreatedActions
<%= work_post.title %><%= truncate(work_post.description, length: 50) %><%= work_post.tags.join(", ") %><%= work_post.featured ? "Yes" : "No" %><%= work_post.created_at.strftime("%b %d, %Y") %> + <%= link_to "Edit", admin_edit_work_post_path(work_post), class: "text-blue-600 hover:underline mr-2" %> + <%= button_to "Delete", admin_destroy_work_post_path(work_post), method: :delete, data: { confirm: "Are you sure?" }, class: "text-red-600 hover:underline bg-transparent border-0 p-0 cursor-pointer" %> +
+ <% else %> +

No work posts found. <%= link_to "Create your first work post", admin_new_work_post_path, class: "text-blue-600 hover:underline" %>.

+ <% end %> +
\ No newline at end of file diff --git a/app/views/frontend/show.html.erb b/app/views/frontend/show.html.erb new file mode 100644 index 0000000..e98e9dc --- /dev/null +++ b/app/views/frontend/show.html.erb @@ -0,0 +1,227 @@ + + + + + + Austin French - Backend Developer + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + + + + + + + + + +
+ +
+

Loading Frontend...

+

If you're seeing this message for a long time, JavaScript might be disabled in your browser.

+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb new file mode 100644 index 0000000..1ba7e3f --- /dev/null +++ b/app/views/layouts/admin.html.erb @@ -0,0 +1,147 @@ + + + + Portfolio Admin + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + +
+
+

Admin Panel

+
+ +
+ +
+ <% if notice %> +
<%= notice %>
+ <% end %> + + <% if alert %> +
<%= alert %>
+ <% end %> + + <%= yield %> +
+ + \ No newline at end of file diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index ec5f73b..003fd67 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -32,8 +32,11 @@
-
+
<%= yield %> +
+ Admin +
diff --git a/app/views/portfolio/arena_shooter.html.erb b/app/views/portfolio/arena_shooter.html.erb index c921a99..019db50 100644 --- a/app/views/portfolio/arena_shooter.html.erb +++ b/app/views/portfolio/arena_shooter.html.erb @@ -56,7 +56,7 @@ diff --git a/components/ApiStatus.tsx b/components/ApiStatus.tsx new file mode 100644 index 0000000..484d943 --- /dev/null +++ b/components/ApiStatus.tsx @@ -0,0 +1,115 @@ + +import { useState, useEffect } from 'react'; +import { Server, Database, Zap, GitMerge } from 'lucide-react'; + +type StatusType = 'online' | 'offline' | 'warning'; + +type Service = { + name: string; + status: StatusType; + latency: number; + icon: React.ReactNode; + message?: string; +}; + +const ApiStatus = () => { + const [services, setServices] = useState([ + { + name: 'API', + status: 'online', + latency: 42, + icon: , + message: '200 OK' + }, + { + name: 'Database', + status: 'online', + latency: 82, + icon: , + message: 'Connected' + }, + { + name: 'Server', + status: 'online', + latency: 5, + icon: , + message: 'Running' + }, + { + name: 'CI/CD', + status: 'warning', + latency: 0, + icon: , + message: 'Build in progress' + } + ]); + + // Simulate random latency changes + useEffect(() => { + const interval = setInterval(() => { + setServices(prev => + prev.map(service => { + const latencyChange = Math.floor(Math.random() * 30) - 10; + const newLatency = Math.max(1, service.latency + latencyChange); + + let status: StatusType = 'online'; + if (newLatency > 200) status = 'warning'; + if (newLatency > 500) status = 'offline'; + + // Don't change CI/CD status + if (service.name === 'CI/CD') return service; + + return { + ...service, + latency: newLatency, + status + }; + }) + ); + }, 5000); + + return () => clearInterval(interval); + }, []); + + return ( +
+
// systems status
+
+ {services.map((service) => ( +
+
+ {service.icon} + {service.name} +
+
+ + {service.message} + +
+
+
+ ))} +
+ +
+
+ uptime: + 99.98% +
+
+ last incident: + 14d ago +
+
+
+ ); +}; + +export default ApiStatus; diff --git a/components/BentoBox.tsx b/components/BentoBox.tsx new file mode 100644 index 0000000..2169651 --- /dev/null +++ b/components/BentoBox.tsx @@ -0,0 +1,148 @@ + +import { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; + +type BentoBoxProps = { + children: ReactNode; + className?: string; + heading?: string; + subheading?: string; + to?: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; + gradient?: boolean; + terminal?: boolean; + status?: 'online' | 'offline' | 'warning' | null; + variant?: 'default' | 'rails' | 'postgres' | 'code'; + animation?: 'none' | 'vu-meter' | 'loading-bar' | 'sine-wave' | 'pulse'; +}; + +const BentoBox = ({ + children, + className = "", + heading, + subheading, + to, + size = 'md', + gradient = false, + terminal = false, + status = null, + variant = 'default', + animation = 'none' +}: BentoBoxProps) => { + + const sizeClasses = { + sm: 'col-span-1 row-span-1', + md: 'col-span-1 row-span-2', + lg: 'col-span-2 row-span-1', + xl: 'col-span-2 row-span-2', + }; + + const statusColors = { + online: 'bg-terminal-success', + offline: 'bg-terminal-error', + warning: 'bg-terminal-warning' + }; + + const variantClasses = { + default: 'border-gray-700', + rails: 'card-rails', + postgres: 'card-postgres', + code: 'border-neon-green/30' + }; + + // Different animation styles + const renderAnimation = () => { + switch(animation) { + case 'vu-meter': + return
; + case 'loading-bar': + return
; + case 'sine-wave': + return
; + case 'pulse': + return
; + default: + return null; + } + }; + + const content = ( +
+ {/* Noise overlay */} +
+ + {/* macOS window title bar for non-terminal boxes */} + {!terminal && heading && ( +
+
+
+
+
+
+
+ {heading.toLowerCase()} +
+
+ )} + + {/* Status indicator */} + {status && ( +
+ {status === 'online' ? 'ONLINE' : status === 'offline' ? 'OFFLINE' : 'WARNING'} +
+
+ )} + + {/* Animation container at the bottom */} +
+ {renderAnimation()} +
+ + {/* Content */} +
+ {/* Heading is now in the title bar */} + {subheading && ( +

{subheading}

+ )} +
{children}
+
+ + {/* Pixel corner decoration */} +
+
+ ); + + if (to) { + return ( + + {content} + + ); + } + + return content; +}; + +export default BentoBox; diff --git a/components/CodeSnippet.tsx b/components/CodeSnippet.tsx new file mode 100644 index 0000000..04bf0a4 --- /dev/null +++ b/components/CodeSnippet.tsx @@ -0,0 +1,91 @@ + +import { ReactNode } from 'react'; + +type CodeSnippetProps = { + children?: ReactNode; + language?: string; + customCode?: boolean; + theme?: 'rails' | 'postgres' | 'default'; +}; + +const CodeSnippet = ({ children, language = 'typescript', customCode = false, theme = 'default' }: CodeSnippetProps) => { + const themeClasses = { + rails: 'bg-[#1c1c1c] border-[#CC0000]/30', + postgres: 'bg-[#1c1c1c] border-[#336791]/30', + default: 'bg-[#1c1c1c] border-[#39ff14]/30' + }; + + const keywordColor = theme === 'rails' ? 'text-[#CC0000]' : + theme === 'postgres' ? 'text-[#336791]' : + 'text-[#569cd6]'; + + const stringColor = theme === 'rails' ? 'text-[#ff9787]' : + theme === 'postgres' ? 'text-[#66c2ff]' : + 'text-[#ce9178]'; + + const commentColor = theme === 'rails' ? 'text-[#888888]' : + theme === 'postgres' ? 'text-[#888888]' : + 'text-[#6a9955]'; + + return ( +
+ {/* MacOS window decorations */} +
+
+
+
+
+
+
+ skills.{language} +
+
+ {/* Line numbers column */} +
+
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9].map(num => ( +
{num}
+ ))} +
+ + {/* Code content */} +
+ {!customCode ? ( +
+              
+                interface Skills {"{"}
+                
+ {" "}backend: 'Node.js | Ruby on Rails'; +
+ {" "}databases: 'MongoDB, PostgreSQL'; +
+ {" "}devops: 'Docker, AWS, CI/CD'; +
+ {" "}architecture: 'Microservices, Serverless'; +
+ {" "}languages: 'TypeScript, Ruby, Go'; +
+ {" "}tools: 'Git, Kubernetes, Terraform'; +
+ {"}"} +
+ // Constantly learning and improving... +
+
+ ) : ( + children + )} +
+
+ + {/* Editor status bar */} +
+
ln 9, col 42
+
UTF-8
+
{language.toUpperCase()}
+
+
+ ); +}; + +export default CodeSnippet; diff --git a/components/GameCard.tsx b/components/GameCard.tsx new file mode 100644 index 0000000..028d5c4 --- /dev/null +++ b/components/GameCard.tsx @@ -0,0 +1,34 @@ +import { FC } from 'react'; + +interface GameCardProps { + title: string; + description: string; + imageUrl?: string; + link: string; +} + +const GameCard: FC = ({ title, description, imageUrl, link }) => { + return ( +
+
+ {title} +
+
+

{title}

+

{description}

+ + Play Now + +
+
+ ); +}; + +export default GameCard; \ No newline at end of file diff --git a/components/GamesGrid.tsx b/components/GamesGrid.tsx new file mode 100644 index 0000000..d4668cb --- /dev/null +++ b/components/GamesGrid.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import GameCard from './GameCard'; + +export interface Game { + title: string; + description: string; + imageUrl?: string; + link: string; +} + +interface GamesGridProps { + games: Game[]; +} + +const GamesGrid: FC = ({ games }) => { + return ( +
+ {games.map((game, index) => ( + + ))} + {games.length === 0 && ( +
+

No games available at the moment.

+
+ )} +
+ ); +}; + +export default GamesGrid; \ No newline at end of file diff --git a/components/MatrixRain.tsx b/components/MatrixRain.tsx new file mode 100644 index 0000000..45a287d --- /dev/null +++ b/components/MatrixRain.tsx @@ -0,0 +1,102 @@ + +import { useEffect, useRef } from 'react'; + +type MatrixRainProps = { + intensity?: 'light' | 'medium' | 'heavy'; + color?: string; +}; + +const MatrixRain = ({ intensity = 'light', color = '#39ff14' }: MatrixRainProps) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Set canvas dimensions + const setCanvasDimensions = () => { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + }; + + setCanvasDimensions(); + window.addEventListener('resize', setCanvasDimensions); + + // Define matrix characters + const characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$+-*/=%"\'#&_(),.;:?!\\|{}<>[]^~'; + const techTerms = ['rails', 'ruby', 'postgres', 'sql', 'node', 'api', 'db', 'terminal']; + + // Determine drop count based on intensity + const getDensityFactor = () => { + switch (intensity) { + case 'light': return 0.01; + case 'medium': return 0.02; + case 'heavy': return 0.05; + default: return 0.01; + } + }; + + const fontSize = 12; + const columns = Math.floor(canvas.width / fontSize); + const densityFactor = getDensityFactor(); + const drops: number[] = Array(columns).fill(1); + const opacities: number[] = Array(columns).fill(0); + + // Animation function + const draw = () => { + ctx.fillStyle = 'rgba(18, 18, 18, 0.05)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.fillStyle = color; + ctx.font = `${fontSize}px JetBrains Mono`; + + // Iterate over each column + for (let i = 0; i < drops.length; i++) { + // Randomly decide whether to draw or not + if (Math.random() < densityFactor) { + // Randomly decide to draw a tech term + if (Math.random() < 0.03) { + const term = techTerms[Math.floor(Math.random() * techTerms.length)]; + ctx.fillStyle = `rgba(${color.replace(/[^\d,]/g, '')}, ${opacities[i]})`; + ctx.fillText(term, i * fontSize, drops[i] * fontSize); + } else { + // Draw random character + const char = characters[Math.floor(Math.random() * characters.length)]; + ctx.fillStyle = `rgba(${color.replace(/[^\d,]/g, '')}, ${opacities[i]})`; + ctx.fillText(char, i * fontSize, drops[i] * fontSize); + } + + // Move drops and reset when they reach bottom + if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) { + drops[i] = 0; + opacities[i] = 0; + } + + // Increment drop position + drops[i]++; + + // Fade in opacity at the beginning + if (opacities[i] < 1) { + opacities[i] += 0.02; + } + } + } + }; + + // Set animation interval + const interval = setInterval(draw, 50); + + return () => { + clearInterval(interval); + window.removeEventListener('resize', setCanvasDimensions); + }; + }, [intensity, color]); + + // Matrix effect is completely disabled + return null; +}; + +export default MatrixRain; diff --git a/components/Navigation.tsx b/components/Navigation.tsx new file mode 100644 index 0000000..0f087e5 --- /dev/null +++ b/components/Navigation.tsx @@ -0,0 +1,133 @@ + +import { useState, useEffect } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { Terminal, FileCode, Database, Wifi, Keyboard, Mic } from 'lucide-react'; + +type NavItem = { + icon: React.ElementType; + label: string; + path: string; + command: string; +}; + +const navItems: NavItem[] = [ + { icon: Terminal, label: 'Home', path: '/', command: 'cd ~' }, + { icon: FileCode, label: 'Blog', path: '/blog', command: 'cat ~/blog/posts.md' }, + { icon: Database, label: 'Work', path: '/work', command: 'ls -la ~/projects/' }, + { icon: Wifi, label: 'Contact', path: '/contact', command: 'curl -X POST api.austin.dev/contact' }, + { icon: Mic, label: 'Fun', path: '/fun', command: 'play ~/music/favorite.mp3' }, + { icon: Keyboard, label: 'Games', path: '/games', command: './games/start.sh' }, +]; + +const Navigation = () => { + const location = useLocation(); + const [activeIndex, setActiveIndex] = useState(0); + const [isTyping, setIsTyping] = useState(false); + const [currentCommand, setCurrentCommand] = useState(''); + const [typedCommand, setTypedCommand] = useState(''); + const [history, setHistory] = useState([]); + + // Set active index based on location + useEffect(() => { + const index = navItems.findIndex(item => item.path === location.pathname); + setActiveIndex(index !== -1 ? index : 0); + const command = navItems[index !== -1 ? index : 0].command; + setCurrentCommand(command); + }, [location]); + + // Type out the command when active index changes + useEffect(() => { + if (currentCommand) { + setIsTyping(true); + setTypedCommand(''); + + let i = 0; + const typeInterval = setInterval(() => { + if (i < currentCommand.length) { + setTypedCommand(prev => prev + currentCommand[i]); + i++; + } else { + clearInterval(typeInterval); + setIsTyping(false); + + // Add command to history after typing is complete + setTimeout(() => { + setHistory(prev => [...prev.slice(-4), currentCommand]); + }, 300); + } + }, 50); + + return () => clearInterval(typeInterval); + } + }, [currentCommand]); + + return ( +
+ +
+ ); +}; + +export default Navigation; diff --git a/components/PixelCursor.tsx b/components/PixelCursor.tsx new file mode 100644 index 0000000..5b25b3b --- /dev/null +++ b/components/PixelCursor.tsx @@ -0,0 +1,86 @@ + +import { useEffect, useState } from 'react'; + +const PixelCursor = () => { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [isClicking, setIsClicking] = useState(false); + const [trail, setTrail] = useState<{x: number, y: number, opacity: number}[]>([]); + + useEffect(() => { + // Hide default cursor + document.body.style.cursor = 'none'; + + const updatePosition = (e: MouseEvent) => { + setPosition({ x: e.clientX, y: e.clientY }); + + // Add to trail with decreasing opacity + setTrail(prev => { + const newTrail = [...prev, { x: e.clientX, y: e.clientY, opacity: 0.7 }]; + if (newTrail.length > 5) { + return newTrail.slice(1).map((point, index) => ({ + ...point, + opacity: 0.7 - (index * 0.12) // Decrease opacity for older points + })); + } + return newTrail; + }); + }; + + const handleMouseDown = () => setIsClicking(true); + const handleMouseUp = () => setIsClicking(false); + + window.addEventListener('mousemove', updatePosition); + window.addEventListener('mousedown', handleMouseDown); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + document.body.style.cursor = 'auto'; + window.removeEventListener('mousemove', updatePosition); + window.removeEventListener('mousedown', handleMouseDown); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + return ( + <> + {/* Cursor trail */} + {trail.map((point, index) => ( +
+ ))} + + {/* Main cursor */} +
+
+ {/* Tech-themed pixelated cursor */} +
+
+
+ + ); +}; + +export default PixelCursor; diff --git a/components/TerminalWindow.tsx b/components/TerminalWindow.tsx new file mode 100644 index 0000000..f5d83e9 --- /dev/null +++ b/components/TerminalWindow.tsx @@ -0,0 +1,182 @@ + +import { useState, useEffect, useRef } from 'react'; + +type CommandHistoryItem = { + command: string; + responses?: string[]; + timestamp?: string; +}; + +type TerminalWindowProps = { + commands?: string[]; + responses?: string[][]; + prompt?: string; + theme?: 'rails' | 'postgres' | 'default' | 'bash'; + autoType?: boolean; + autoScroll?: boolean; + showTimestamps?: boolean; + height?: string; +}; + +const TerminalWindow = ({ + commands = [], + responses = [], + prompt = "$ ", + theme = 'default', + autoType = true, + autoScroll = true, + showTimestamps = true, + height = "100%" +}: TerminalWindowProps) => { + const [history, setHistory] = useState([]); + const [currentCommand, setCurrentCommand] = useState(""); + const [typeIndex, setTypeIndex] = useState(0); + const [commandIndex, setCommandIndex] = useState(0); + const outputRef = useRef(null); + + // Theme settings + const themeColors = { + rails: { + prompt: "#CC0000", + text: "#d0d0d0", + background: "#1a1a1a", + success: "#32CD32" + }, + postgres: { + prompt: "#336791", + text: "#d0d0d0", + background: "#1a1a1a", + success: "#32CD32" + }, + default: { + prompt: "#39ff14", + text: "#d0d0d0", + background: "#1a1a1a", + success: "#32CD32" + }, + bash: { + prompt: "#FFFFFF", + text: "#d0d0d0", + background: "#1a1a1a", + success: "#32CD32" + } + }; + + const currentTheme = themeColors[theme]; + + // Generate a timestamp in terminal format + const generateTimestamp = () => { + const now = new Date(); + const hours = now.getHours().toString().padStart(2, '0'); + const minutes = now.getMinutes().toString().padStart(2, '0'); + const seconds = now.getSeconds().toString().padStart(2, '0'); + return `[${hours}:${minutes}:${seconds}]`; + }; + + // Add a command to the history + const addCommandToHistory = (cmd: string, resp?: string[]) => { + setHistory(prev => [ + ...prev, + { + command: cmd, + responses: resp || ["Command executed successfully"], + timestamp: showTimestamps ? generateTimestamp() : undefined + } + ]); + }; + + // Simulate typing + useEffect(() => { + if (!autoType || commands.length === 0 || commandIndex >= commands.length) return; + + const currentCmd = commands[commandIndex]; + + if (typeIndex < currentCmd.length) { + const typingTimer = setTimeout(() => { + setCurrentCommand(prev => prev + currentCmd[typeIndex]); + setTypeIndex(prev => prev + 1); + }, Math.random() * 50 + 30); // Random typing speed for realism + + return () => clearTimeout(typingTimer); + } else { + // Finished typing the command + const commandResponses = responses[commandIndex] || ["Command executed successfully"]; + + const executionTimer = setTimeout(() => { + addCommandToHistory(currentCmd, commandResponses); + setCurrentCommand(""); + setTypeIndex(0); + setCommandIndex(prev => prev + 1); + }, 500); // Wait before executing + + return () => clearTimeout(executionTimer); + } + }, [autoType, commands, commandIndex, typeIndex, responses]); + + // Auto-scroll to bottom + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [history, currentCommand]); + + return ( +
+ {/* Terminal header with macOS buttons */} +
+
+
+
+
+
+
+ {theme === 'rails' ? 'rails console' : theme === 'postgres' ? 'psql shell' : 'bash'} +
+
+
+ {history.map((item, i) => ( +
+
+ {prompt} + {item.command} +
+ + {item.responses && item.responses.map((response, j) => ( +
+ {response} +
+ ))} + + {item.timestamp && ( +
{item.timestamp}
+ )} +
+ ))} + + {/* Current typing command */} + {currentCommand && ( +
+
+ {prompt} + + {currentCommand}â–‹ + +
+
+ )} +
+ + {/* Terminal status bar */} +
+
session: {theme === 'rails' ? 'rails' : theme === 'postgres' ? 'psql' : 'bash'}
+
utf-8
+
exit: ctrl+d
+
+
+ ); +}; + +export default TerminalWindow; diff --git a/components/WorkCard.tsx b/components/WorkCard.tsx new file mode 100644 index 0000000..d69cfdf --- /dev/null +++ b/components/WorkCard.tsx @@ -0,0 +1,42 @@ +import { FC } from 'react'; + +export interface WorkCardProps { + id: number; + title: string; + description: string; + tags: string[]; + image: string; +} + +const WorkCard: FC = ({ title, description, tags, image }) => { + return ( +
+
+ {title} +
+
+
+

{title}

+

{description}

+
+ {tags.map((tag) => ( + + {tag} + + ))} +
+
+ +
+
+
+ ); +}; + +export default WorkCard; \ No newline at end of file diff --git a/components/WorkGrid.tsx b/components/WorkGrid.tsx new file mode 100644 index 0000000..4b08436 --- /dev/null +++ b/components/WorkGrid.tsx @@ -0,0 +1,30 @@ +import { FC } from 'react'; +import WorkCard, { WorkCardProps } from './WorkCard'; + +interface WorkGridProps { + projects: WorkCardProps[]; +} + +const WorkGrid: FC = ({ projects }) => { + return ( +
+ {projects.map((project) => ( + + ))} + {projects.length === 0 && ( +
+

No projects available at the moment.

+
+ )} +
+ ); +}; + +export default WorkGrid; \ No newline at end of file diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..e6a723d --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..8722561 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c4abbf3 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..991f56e --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..71a5c32 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>