mirror of
https://github.com/mmahdium/portfolio.git
synced 2026-08-17 05:24:30 +03:30
feat(blog): enhance content styling and add new blog post with sharing capabilities
- Refine blog typography with optimized font sizes and line heights for better readability - Adjust heading margins and sizes (h1-h4) for improved visual hierarchy - Implement custom bullet styling with primary color indicators for lists - Add highlighted key phrases styling with left border accent for emphasized content - Create new BlogShare component for social sharing functionality - Add Callout and PullQuote content components for enhanced blog formatting - Introduce resume-button.css for improved button styling - Update blog post template with slug-based routing support - Add new blog post "Career Change: From Huawei to Frontend" in English and Persian - Enhance portfolio components (Hero, Skills, AIStack, etc.) with refined styling - Improve RTL language support with better spacing and alignment for Persian content - Add blog hero image asset (big-career-change.webp) - Update i18n translations for new blog content and UI improvements - Optimize code block and inline code styling with CSS variables for consistency - Refine blockquote styling with better contrast and spacing
This commit is contained in:
+136
-70
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
/* Base typography */
|
/* Base typography */
|
||||||
.blog-content {
|
.blog-content {
|
||||||
font-size: 1.125rem; /* 18px */
|
font-size: 1.0625rem; /* 17px - کمی کوچکتر */
|
||||||
line-height: 2; /* خطفاصله بیشتر */
|
line-height: 1.75; /* خطفاصله متعادلتر */
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
color: rgb(55, 65, 81);
|
color: rgb(55, 65, 81);
|
||||||
max-width: 75ch;
|
max-width: 70ch; /* کمی باریکتر برای خوانایی بهتر */
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -18,8 +18,9 @@
|
|||||||
|
|
||||||
/* RTL-specific improvements */
|
/* RTL-specific improvements */
|
||||||
.blog-content-rtl {
|
.blog-content-rtl {
|
||||||
line-height: 2.2 !important; /* فارسی نیاز به فاصله بیشتری داره */
|
line-height: 1.9 !important; /* فارسی نیاز به فاصله بیشتری داره ولی نه خیلی زیاد */
|
||||||
letter-spacing: 0.02em !important;
|
letter-spacing: 0.015em !important;
|
||||||
|
font-size: 1.0625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Force LTR for code blocks in RTL */
|
/* Force LTR for code blocks in RTL */
|
||||||
@@ -38,11 +39,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.blog-content h1 {
|
.blog-content h1 {
|
||||||
margin-top: 3rem;
|
margin-top: 2.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
line-height: 1.4;
|
line-height: 1.3;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 2.25em;
|
font-size: 2em;
|
||||||
color: rgb(17, 24, 39);
|
color: rgb(17, 24, 39);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +52,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.blog-content h2 {
|
.blog-content h2 {
|
||||||
margin-top: 3rem;
|
margin-top: 2.5rem;
|
||||||
margin-bottom: 1.75rem;
|
margin-bottom: 1.25rem;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.875em;
|
font-size: 1.75em;
|
||||||
color: rgb(17, 24, 39);
|
color: rgb(17, 24, 39);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,11 +65,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.blog-content h3 {
|
.blog-content h3 {
|
||||||
margin-top: 2.5rem;
|
margin-top: 2rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.5;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1.5em;
|
font-size: 1.375em;
|
||||||
color: rgb(17, 24, 39);
|
color: rgb(17, 24, 39);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +78,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.blog-content h4 {
|
.blog-content h4 {
|
||||||
margin-top: 2rem;
|
margin-top: 1.75rem;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 0.875rem;
|
||||||
line-height: 1.6;
|
line-height: 1.5;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1.25em;
|
font-size: 1.125em;
|
||||||
color: rgb(17, 24, 39);
|
color: rgb(17, 24, 39);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,10 +90,10 @@
|
|||||||
color: rgb(243, 244, 246);
|
color: rgb(243, 244, 246);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Paragraphs - فاصله زیاد برای خوانایی بهتر */
|
/* Paragraphs - فاصله متعادل */
|
||||||
.blog-content p {
|
.blog-content p {
|
||||||
margin-top: 2rem;
|
margin-top: 1.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,14 +102,14 @@
|
|||||||
.blog-content h2 + p,
|
.blog-content h2 + p,
|
||||||
.blog-content h3 + p,
|
.blog-content h3 + p,
|
||||||
.blog-content h4 + p {
|
.blog-content h4 + p {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lists */
|
/* Lists */
|
||||||
.blog-content ul,
|
.blog-content ul,
|
||||||
.blog-content ol {
|
.blog-content ol {
|
||||||
margin-top: 2.25rem;
|
margin-top: 1.5rem;
|
||||||
margin-bottom: 2.25rem;
|
margin-bottom: 1.5rem;
|
||||||
padding-left: 2rem;
|
padding-left: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,14 +119,48 @@
|
|||||||
padding-right: 2rem;
|
padding-right: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Better bullet styling with custom bullets */
|
||||||
|
.blog-content ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-content ul > li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-content ul > li::before {
|
||||||
|
content: "●";
|
||||||
|
position: absolute;
|
||||||
|
left: 0.25rem;
|
||||||
|
color: var(--ui-color-primary-500);
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .blog-content ul > li::before {
|
||||||
|
color: var(--ui-color-primary-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-content-rtl ul > li {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-content-rtl ul > li::before {
|
||||||
|
left: auto;
|
||||||
|
right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.blog-content li {
|
.blog-content li {
|
||||||
margin-top: 1rem;
|
margin-top: 0.75rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.75rem;
|
||||||
line-height: 2;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-content-rtl li {
|
.blog-content-rtl li {
|
||||||
line-height: 2.2;
|
line-height: 1.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-content li p {
|
.blog-content li p {
|
||||||
@@ -152,12 +187,41 @@
|
|||||||
color: rgb(243, 244, 246);
|
color: rgb(243, 244, 246);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Highlighted key phrases - for emphasized sentences */
|
||||||
|
.blog-content em strong,
|
||||||
|
.blog-content strong em {
|
||||||
|
background-color: var(--ui-color-primary-50);
|
||||||
|
border-left: 3px solid var(--ui-color-primary-500);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0.125rem 0;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .blog-content em strong,
|
||||||
|
.dark .blog-content strong em {
|
||||||
|
background-color: color-mix(in srgb, var(--ui-color-primary-950) 30%, transparent);
|
||||||
|
border-left-color: var(--ui-color-primary-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-content-rtl em strong,
|
||||||
|
.blog-content-rtl strong em {
|
||||||
|
border-left: none;
|
||||||
|
border-right: 3px solid var(--ui-color-primary-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .blog-content-rtl em strong,
|
||||||
|
.dark .blog-content-rtl strong em {
|
||||||
|
border-right-color: var(--ui-color-primary-400);
|
||||||
|
}
|
||||||
|
|
||||||
/* Code blocks */
|
/* Code blocks */
|
||||||
.blog-content pre {
|
.blog-content pre {
|
||||||
margin-top: 3rem;
|
margin-top: 2rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 2rem;
|
||||||
line-height: 1.7;
|
line-height: 1.6;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.875rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
@@ -171,42 +235,42 @@
|
|||||||
/* Inline code */
|
/* Inline code */
|
||||||
.blog-content code:not(pre code) {
|
.blog-content code:not(pre code) {
|
||||||
padding: 0.25em 0.5em;
|
padding: 0.25em 0.5em;
|
||||||
background-color: rgba(99, 102, 241, 0.1);
|
background-color: var(--ui-color-primary-100);
|
||||||
|
color: var(--ui-color-primary-700);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: rgb(79, 70, 229);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .blog-content code:not(pre code) {
|
.dark .blog-content code:not(pre code) {
|
||||||
background-color: rgba(99, 102, 241, 0.2);
|
background-color: color-mix(in srgb, var(--ui-color-primary-950) 40%, transparent);
|
||||||
color: rgb(165, 180, 252);
|
color: var(--ui-color-primary-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blockquotes */
|
/* Blockquotes */
|
||||||
.blog-content blockquote {
|
.blog-content blockquote {
|
||||||
margin-top: 3rem;
|
margin-top: 2rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 2rem;
|
||||||
padding: 1.75rem;
|
padding: 1.5rem;
|
||||||
border-left: 4px solid rgb(99, 102, 241);
|
border-left: 4px solid var(--ui-color-primary-500);
|
||||||
background-color: rgba(99, 102, 241, 0.05);
|
background-color: var(--ui-color-primary-50);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
line-height: 2;
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .blog-content blockquote {
|
||||||
|
border-left-color: var(--ui-color-primary-400);
|
||||||
|
background-color: color-mix(in srgb, var(--ui-color-primary-950) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-content-rtl blockquote {
|
.blog-content-rtl blockquote {
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-right: 4px solid rgb(99, 102, 241);
|
border-right: 4px solid var(--ui-color-primary-500);
|
||||||
}
|
|
||||||
|
|
||||||
.dark .blog-content blockquote {
|
|
||||||
background-color: rgba(99, 102, 241, 0.1);
|
|
||||||
border-left-color: rgb(129, 140, 248);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .blog-content-rtl blockquote {
|
.dark .blog-content-rtl blockquote {
|
||||||
border-right-color: rgb(129, 140, 248);
|
border-right-color: var(--ui-color-primary-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-content blockquote p {
|
.blog-content blockquote p {
|
||||||
@@ -216,31 +280,33 @@
|
|||||||
|
|
||||||
/* Links */
|
/* Links */
|
||||||
.blog-content a {
|
.blog-content a {
|
||||||
color: rgb(99, 102, 241);
|
color: var(--ui-color-primary-600);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-decoration-color: rgba(99, 102, 241, 0.3);
|
text-decoration-color: var(--ui-color-primary-300);
|
||||||
text-underline-offset: 0.25em;
|
text-underline-offset: 0.25em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-content a:hover {
|
.dark .blog-content a {
|
||||||
color: rgb(79, 70, 229);
|
color: var(--ui-color-primary-400);
|
||||||
text-decoration-color: rgba(99, 102, 241, 0.8);
|
text-decoration-color: var(--ui-color-primary-600);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .blog-content a {
|
.blog-content a:hover {
|
||||||
color: rgb(129, 140, 248);
|
color: var(--ui-color-primary-700);
|
||||||
|
text-decoration-color: var(--ui-color-primary-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .blog-content a:hover {
|
.dark .blog-content a:hover {
|
||||||
color: rgb(165, 180, 252);
|
color: var(--ui-color-primary-300);
|
||||||
|
text-decoration-color: var(--ui-color-primary-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Horizontal rule */
|
/* Horizontal rule */
|
||||||
.blog-content hr {
|
.blog-content hr {
|
||||||
margin-top: 3.5rem;
|
margin-top: 2.5rem;
|
||||||
margin-bottom: 3.5rem;
|
margin-bottom: 2.5rem;
|
||||||
border-color: rgba(148, 163, 184, 0.3);
|
border-color: rgba(148, 163, 184, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,19 +316,19 @@
|
|||||||
|
|
||||||
/* Images */
|
/* Images */
|
||||||
.blog-content img {
|
.blog-content img {
|
||||||
margin-top: 3rem;
|
margin-top: 2rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 2rem;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tables */
|
/* Tables */
|
||||||
.blog-content table {
|
.blog-content table {
|
||||||
margin-top: 3rem;
|
margin-top: 2rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 2rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.875rem;
|
||||||
display: block;
|
display: block;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -282,13 +348,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.blog-content th {
|
.blog-content th {
|
||||||
background-color: rgba(99, 102, 241, 0.1);
|
background-color: var(--ui-color-primary-100);
|
||||||
font-weight: 600;
|
|
||||||
color: rgb(17, 24, 39);
|
color: rgb(17, 24, 39);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .blog-content th {
|
.dark .blog-content th {
|
||||||
background-color: rgba(99, 102, 241, 0.15);
|
background-color: color-mix(in srgb, var(--ui-color-primary-950) 30%, transparent);
|
||||||
color: rgb(243, 244, 246);
|
color: rgb(243, 244, 246);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+96
-12
@@ -5,6 +5,7 @@
|
|||||||
@import "./transitions.css";
|
@import "./transitions.css";
|
||||||
@import "./prose.css";
|
@import "./prose.css";
|
||||||
@import "./blog-content.css";
|
@import "./blog-content.css";
|
||||||
|
@import "./resume-button.css";
|
||||||
|
|
||||||
@source "../../components/**/*.{vue,js,ts}";
|
@source "../../components/**/*.{vue,js,ts}";
|
||||||
@source "../../layouts/**/*.vue";
|
@source "../../layouts/**/*.vue";
|
||||||
@@ -146,15 +147,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--scrollbar-track: color-mix(in oklch, #e0e7ff 35%, transparent);
|
--scrollbar-track: color-mix(in srgb, var(--ui-color-primary-100) 35%, transparent);
|
||||||
--scrollbar-thumb: color-mix(in oklch, #7c3aed 65%, #6d28d9 35%);
|
--scrollbar-thumb: color-mix(in srgb, var(--ui-color-primary-500) 65%, var(--ui-color-primary-600) 35%);
|
||||||
--scrollbar-thumb-hover: color-mix(in oklch, #7c3aed 80%, #a855f7 20%);
|
--scrollbar-thumb-hover: color-mix(in srgb, var(--ui-color-primary-500) 80%, var(--ui-color-primary-400) 20%);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root.dark {
|
:root.dark {
|
||||||
--scrollbar-track: color-mix(in oklch, #0b1020 80%, #7c3aed 20%);
|
--scrollbar-track: color-mix(in srgb, #0b1020 80%, var(--ui-color-primary-500) 20%);
|
||||||
--scrollbar-thumb: color-mix(in oklch, #a855f7 65%, #7c3aed 35%);
|
--scrollbar-thumb: color-mix(in srgb, var(--ui-color-primary-400) 65%, var(--ui-color-primary-500) 35%);
|
||||||
--scrollbar-thumb-hover: color-mix(in oklch, #c084fc 75%, #8b5cf6 25%);
|
--scrollbar-thumb-hover: color-mix(in srgb, var(--ui-color-primary-300) 75%, var(--ui-color-primary-400) 25%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global scrollbar styling */
|
/* Global scrollbar styling */
|
||||||
@@ -297,16 +298,51 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glass-card:hover {
|
.glass-card:hover {
|
||||||
@apply shadow-2xl shadow-violet-500/10 dark:shadow-violet-500/20 translate-y-[-2px];
|
@apply translate-y-[-2px];
|
||||||
|
box-shadow:
|
||||||
|
0 25px 50px -12px color-mix(in srgb, var(--ui-color-primary-500) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .glass-card:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 25px 50px -12px color-mix(in srgb, var(--ui-color-primary-500) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global hover utilities for consistent interactions */
|
/* Global hover utilities for consistent interactions - using dynamic theme colors */
|
||||||
.hover-ring-tint {
|
.hover-ring-tint {
|
||||||
@apply transition-all duration-300 ease-out ring-0 hover:ring-2 hover:ring-violet-500 dark:hover:ring-violet-400 hover:bg-violet-50/60 dark:hover:bg-violet-400/10 focus-visible:ring-2 focus-visible:ring-violet-500 dark:focus-visible:ring-violet-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900;
|
@apply transition-all duration-300 ease-out focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-ring-tint:hover {
|
||||||
|
box-shadow: 0 0 0 2px var(--ui-color-primary-500);
|
||||||
|
background-color: color-mix(in srgb, var(--ui-color-primary-50) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover-ring-tint:hover {
|
||||||
|
box-shadow: 0 0 0 2px var(--ui-color-primary-400);
|
||||||
|
background-color: color-mix(in srgb, var(--ui-color-primary-400) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-ring-tint:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--ui-color-primary-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover-ring-tint:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--ui-color-primary-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-hover-clean {
|
.link-hover-clean {
|
||||||
@apply transition-all duration-300 hover:text-violet-600 dark:hover:text-violet-400 hover:underline underline-offset-4 decoration-violet-500/60 dark:decoration-violet-400/60;
|
@apply transition-all duration-300 hover:underline underline-offset-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-hover-clean:hover {
|
||||||
|
color: var(--ui-color-primary-600);
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--ui-color-primary-500) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .link-hover-clean:hover {
|
||||||
|
color: var(--ui-color-primary-400);
|
||||||
|
text-decoration-color: color-mix(in srgb, var(--ui-color-primary-400) 60%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +350,12 @@
|
|||||||
|
|
||||||
/* Minimal hover: subtle bg tint, slight shadow, no ring/border change */
|
/* Minimal hover: subtle bg tint, slight shadow, no ring/border change */
|
||||||
.hover-minimal {
|
.hover-minimal {
|
||||||
@apply transition-all duration-300 ease-out hover:bg-violet-50/50 dark:hover:bg-white/5 hover:shadow-lg hover:shadow-violet-500/5;
|
@apply transition-all duration-300 ease-out dark:hover:bg-white/5 hover:shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-minimal:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--ui-color-primary-50) 50%, transparent);
|
||||||
|
box-shadow: 0 10px 15px -3px color-mix(in srgb, var(--ui-color-primary-500) 5%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modern button with gradient */
|
/* Modern button with gradient */
|
||||||
@@ -335,7 +376,50 @@
|
|||||||
|
|
||||||
/* Chip-style icon button inspired by SkillGrid: subtle ring, soft bg, minimal hover */
|
/* Chip-style icon button inspired by SkillGrid: subtle ring, soft bg, minimal hover */
|
||||||
.chip-button {
|
.chip-button {
|
||||||
@apply inline-flex items-center justify-center h-12 w-12 rounded-2xl ring-1 ring-violet-200/70 dark:ring-violet-800/50 bg-gradient-to-br from-white/90 to-violet-50/50 dark:from-white/10 dark:to-violet-950/30 text-violet-700 dark:text-violet-200 shadow-lg shadow-violet-500/10 backdrop-blur-md transition-all duration-300 ease-out hover:scale-110 hover:shadow-xl hover:shadow-violet-500/20 hover:from-violet-50 hover:to-violet-100 dark:hover:from-white/15 dark:hover:to-violet-900/40;
|
@apply inline-flex items-center justify-center h-12 w-12 rounded-2xl ring-1 backdrop-blur-md transition-all duration-300 ease-out hover:scale-110;
|
||||||
|
|
||||||
|
/* Dynamic theme colors using CSS variables */
|
||||||
|
ring-color: color-mix(in srgb, var(--ui-color-primary-200) 70%, transparent);
|
||||||
|
background: linear-gradient(to bottom right,
|
||||||
|
color-mix(in srgb, white 90%, transparent),
|
||||||
|
color-mix(in srgb, var(--ui-color-primary-50) 50%, transparent)
|
||||||
|
);
|
||||||
|
color: var(--ui-color-primary-700);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px color-mix(in srgb, var(--ui-color-primary-500) 10%, transparent),
|
||||||
|
0 4px 6px -4px color-mix(in srgb, var(--ui-color-primary-500) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-button:hover {
|
||||||
|
background: linear-gradient(to bottom right,
|
||||||
|
color-mix(in srgb, var(--ui-color-primary-50) 100%, transparent),
|
||||||
|
color-mix(in srgb, var(--ui-color-primary-100) 100%, transparent)
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 25px -5px color-mix(in srgb, var(--ui-color-primary-500) 20%, transparent),
|
||||||
|
0 8px 10px -6px color-mix(in srgb, var(--ui-color-primary-500) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .chip-button {
|
||||||
|
ring-color: color-mix(in srgb, var(--ui-color-primary-800) 50%, transparent);
|
||||||
|
background: linear-gradient(to bottom right,
|
||||||
|
color-mix(in srgb, white 10%, transparent),
|
||||||
|
color-mix(in srgb, var(--ui-color-primary-950) 30%, transparent)
|
||||||
|
);
|
||||||
|
color: var(--ui-color-primary-200);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px color-mix(in srgb, var(--ui-color-primary-500) 5%, transparent),
|
||||||
|
0 4px 6px -4px color-mix(in srgb, var(--ui-color-primary-500) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .chip-button:hover {
|
||||||
|
background: linear-gradient(to bottom right,
|
||||||
|
color-mix(in srgb, white 15%, transparent),
|
||||||
|
color-mix(in srgb, var(--ui-color-primary-900) 40%, transparent)
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 25px -5px color-mix(in srgb, var(--ui-color-primary-500) 10%, transparent),
|
||||||
|
0 8px 10px -6px color-mix(in srgb, var(--ui-color-primary-500) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GitHub Activity Contribution Graph - Dynamic Primary Color */
|
/* GitHub Activity Contribution Graph - Dynamic Primary Color */
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/* Resume Button with Dynamic Gradient */
|
||||||
|
.resume-gradient-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.875rem 1.75rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
border-radius: 9999px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
/* Dynamic gradient using theme colors */
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--ui-color-primary-600) 0%,
|
||||||
|
var(--ui-color-primary-500) 50%,
|
||||||
|
color-mix(in srgb, var(--ui-color-primary-500) 80%, #ec4899 20%) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Glow effect */
|
||||||
|
box-shadow:
|
||||||
|
0 0 30px color-mix(in srgb, var(--ui-color-primary-500) 40%, transparent),
|
||||||
|
0 10px 25px color-mix(in srgb, var(--ui-color-primary-600) 30%, transparent),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-gradient-button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow:
|
||||||
|
0 0 40px color-mix(in srgb, var(--ui-color-primary-500) 60%, transparent),
|
||||||
|
0 0 60px color-mix(in srgb, var(--ui-color-primary-400) 40%, transparent),
|
||||||
|
0 15px 35px color-mix(in srgb, var(--ui-color-primary-600) 40%, transparent),
|
||||||
|
0 6px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shine effect on hover */
|
||||||
|
.resume-gradient-button::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.3),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transition: left 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-gradient-button:hover::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments */
|
||||||
|
.dark .resume-gradient-button {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--ui-color-primary-500) 0%,
|
||||||
|
var(--ui-color-primary-600) 50%,
|
||||||
|
color-mix(in srgb, var(--ui-color-primary-600) 80%, #ec4899 20%) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 0 35px color-mix(in srgb, var(--ui-color-primary-400) 50%, transparent),
|
||||||
|
0 10px 30px color-mix(in srgb, var(--ui-color-primary-500) 40%, transparent),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .resume-gradient-button:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 0 50px color-mix(in srgb, var(--ui-color-primary-400) 70%, transparent),
|
||||||
|
0 0 80px color-mix(in srgb, var(--ui-color-primary-300) 50%, transparent),
|
||||||
|
0 15px 40px color-mix(in srgb, var(--ui-color-primary-500) 50%, transparent),
|
||||||
|
0 6px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ const handleImageError = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<h1 class="text-4xl md:text-5xl font-bold mb-4 text-gray-900 dark:text-gray-100">
|
<h1 class="text-2xl md:text-3xl lg:text-4xl font-bold mb-4 text-gray-900 dark:text-gray-100 leading-tight">
|
||||||
{{ post.title }}
|
{{ post.title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const shareLinks = computed(() => ({
|
||||||
|
twitter: `https://twitter.com/intent/tweet?text=${encodeURIComponent(props.title)}&url=${encodeURIComponent(props.url)}`,
|
||||||
|
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(props.url)}`,
|
||||||
|
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(props.url)}`,
|
||||||
|
telegram: `https://t.me/share/url?url=${encodeURIComponent(props.url)}&text=${encodeURIComponent(props.title)}`
|
||||||
|
}))
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
if (navigator?.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(props.url)
|
||||||
|
toast.add({
|
||||||
|
title: t('blog.linkCopied') || 'Link copied!',
|
||||||
|
icon: 'i-heroicons-check-circle',
|
||||||
|
color: 'green'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textArea = document.createElement('textarea')
|
||||||
|
textArea.value = props.url
|
||||||
|
textArea.style.position = 'fixed'
|
||||||
|
textArea.style.left = '-999999px'
|
||||||
|
document.body.appendChild(textArea)
|
||||||
|
textArea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
toast.add({
|
||||||
|
title: t('blog.linkCopied') || 'Link copied!',
|
||||||
|
icon: 'i-heroicons-check-circle',
|
||||||
|
color: 'green'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err)
|
||||||
|
toast.add({
|
||||||
|
title: t('blog.copyFailed') || 'Failed to copy link',
|
||||||
|
icon: 'i-heroicons-x-circle',
|
||||||
|
color: 'red'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="blog-share my-8 p-6 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-800">
|
||||||
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-heroicons-share" class="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ t('blog.sharePost') || 'Share this post' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Share Buttons -->
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<!-- Twitter -->
|
||||||
|
<UButton :to="shareLinks.twitter" target="_blank" rel="noopener noreferrer" color="gray" variant="ghost"
|
||||||
|
size="sm" icon="i-simple-icons-x" aria-label="Share on Twitter" />
|
||||||
|
|
||||||
|
<!-- LinkedIn -->
|
||||||
|
<UButton :to="shareLinks.linkedin" target="_blank" rel="noopener noreferrer" color="gray" variant="ghost"
|
||||||
|
size="sm" icon="i-simple-icons-linkedin" aria-label="Share on LinkedIn" />
|
||||||
|
|
||||||
|
<!-- Facebook -->
|
||||||
|
<UButton :to="shareLinks.facebook" target="_blank" rel="noopener noreferrer" color="gray" variant="ghost"
|
||||||
|
size="sm" icon="i-simple-icons-facebook" aria-label="Share on Facebook" />
|
||||||
|
|
||||||
|
<!-- Telegram -->
|
||||||
|
<UButton :to="shareLinks.telegram" target="_blank" rel="noopener noreferrer" color="gray" variant="ghost"
|
||||||
|
size="sm" icon="i-simple-icons-telegram" aria-label="Share on Telegram" />
|
||||||
|
|
||||||
|
<!-- Copy Link -->
|
||||||
|
<UButton @click="copyToClipboard" color="gray" variant="ghost" size="sm" icon="i-heroicons-link"
|
||||||
|
aria-label="Copy link" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
type?: 'info' | 'warning' | 'success' | 'tip' | 'primary'
|
||||||
|
title?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const typeConfig = {
|
||||||
|
primary: {
|
||||||
|
icon: 'i-heroicons-light-bulb',
|
||||||
|
color: 'primary',
|
||||||
|
bgClass: 'bg-primary-50 dark:bg-primary-950/30 border-primary-200 dark:border-primary-800'
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
icon: 'i-heroicons-information-circle',
|
||||||
|
color: 'blue',
|
||||||
|
bgClass: 'bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: 'i-heroicons-exclamation-triangle',
|
||||||
|
color: 'yellow',
|
||||||
|
bgClass: 'bg-yellow-50 dark:bg-yellow-950/30 border-yellow-200 dark:border-yellow-800'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
icon: 'i-heroicons-check-circle',
|
||||||
|
color: 'green',
|
||||||
|
bgClass: 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800'
|
||||||
|
},
|
||||||
|
tip: {
|
||||||
|
icon: 'i-heroicons-light-bulb',
|
||||||
|
color: 'purple',
|
||||||
|
bgClass: 'bg-purple-50 dark:bg-purple-950/30 border-purple-200 dark:border-purple-800'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = typeConfig[props.type || 'info']
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['callout-box', config.bgClass]" class="my-6 p-5 rounded-lg border-2 shadow-sm">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<UIcon :name="config.icon" class="w-5 h-5 flex-shrink-0 mt-0.5"
|
||||||
|
:class="`text-${config.color}-600 dark:text-${config.color}-400`" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div v-if="title" class="font-semibold text-base mb-1.5"
|
||||||
|
:class="`text-${config.color}-900 dark:text-${config.color}-100`">
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.callout-box {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout-box:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
author?: string
|
||||||
|
highlight?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pull-quote my-8 relative">
|
||||||
|
<div :class="[
|
||||||
|
'relative p-6 rounded-lg',
|
||||||
|
highlight
|
||||||
|
? 'bg-gradient-to-br from-primary-50 to-primary-100 dark:from-primary-950/40 dark:to-primary-900/40 border-2 border-primary-200 dark:border-primary-800'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-900/50 border-l-4 border-primary-500 dark:border-primary-400'
|
||||||
|
]">
|
||||||
|
<!-- Quote Icon -->
|
||||||
|
<div class="absolute top-4 left-4 opacity-10 dark:opacity-5">
|
||||||
|
<UIcon name="i-heroicons-chat-bubble-left-right" class="w-16 h-16 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="relative z-10">
|
||||||
|
<blockquote class="text-lg md:text-xl font-medium leading-relaxed text-gray-900 dark:text-gray-100 italic">
|
||||||
|
<slot />
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<!-- Author -->
|
||||||
|
<div v-if="author" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<cite class="text-sm font-semibold text-primary-600 dark:text-primary-400 not-italic">
|
||||||
|
— {{ author }}
|
||||||
|
</cite>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pull-quote {
|
||||||
|
animation: fadeInUp 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -33,16 +33,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-3">
|
<UAccordion type="single" :unmount-on-hide="false" :items="accordionItems" default-value="ai-stack"
|
||||||
<UCard class="md:col-span-3">
|
:ui="accordionUi">
|
||||||
<template #header>
|
<template #body>
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
|
||||||
{{ headerTitle }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
<div v-for="item in filtered" :key="item.id" class="inline-flex items-stretch">
|
<div v-for="item in filtered" :key="item.id" class="inline-flex items-stretch">
|
||||||
<UTooltip :arrow="true" :delay-duration="0.5" :text="item.shortWhy || item.name"
|
<UTooltip :arrow="true" :delay-duration="0.5" :text="item.shortWhy || item.name"
|
||||||
@@ -51,24 +44,36 @@
|
|||||||
<span class="inline-flex items-center gap-1.5">
|
<span class="inline-flex items-center gap-1.5">
|
||||||
<UIcon v-if="item.icon" :name="item.icon" class="h-4 w-4 min-h-4 min-w-4 text-base" />
|
<UIcon v-if="item.icon" :name="item.icon" class="h-4 w-4 min-h-4 min-w-4 text-base" />
|
||||||
<span class="text-xs font-medium cursor-default">{{ item.name }}</span>
|
<span class="text-xs font-medium cursor-default">{{ item.name }}</span>
|
||||||
<!-- <UBadge size="xs" color="neutral" variant="subtle" class="ml-1">{{ groupLabel(item.group) }}
|
|
||||||
</UBadge> -->
|
|
||||||
</span>
|
</span>
|
||||||
</UBadge>
|
</UBadge>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</template>
|
||||||
</div>
|
</UAccordion>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { AI_GROUPS, aiStackItems, type AiGroup } from '@/data/aiStack'
|
import { AI_GROUPS, aiStackItems, type AiGroup } from '@/data/aiStack'
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Detect mobile for accordion behavior (SSR-safe)
|
||||||
|
const isMobile = ref(true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth < 768
|
||||||
|
}
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
type GroupMeta = { labelKey: string; icon: string }
|
type GroupMeta = { labelKey: string; icon: string }
|
||||||
const GROUP_META: Record<AiGroup, GroupMeta> = {
|
const GROUP_META: Record<AiGroup, GroupMeta> = {
|
||||||
ide_dev: { labelKey: 'ai_stack.group.ide_dev', icon: 'i-mdi-laptop' },
|
ide_dev: { labelKey: 'ai_stack.group.ide_dev', icon: 'i-mdi-laptop' },
|
||||||
@@ -128,17 +133,30 @@ const filtered = computed(() => {
|
|||||||
return aiStackItems.filter(i => selectedGroups.value.includes(i.group))
|
return aiStackItems.filter(i => selectedGroups.value.includes(i.group))
|
||||||
})
|
})
|
||||||
|
|
||||||
const groupLabel = (g: AiGroup) => t(GROUP_META[g].labelKey)
|
|
||||||
|
|
||||||
const headerTitle = computed(() => {
|
const headerTitle = computed(() => {
|
||||||
if (selectedGroups.value.length === 1) return groupLabel(selectedGroups.value[0]!)
|
if (selectedGroups.value.length === 1) return t(GROUP_META[selectedGroups.value[0]!].labelKey)
|
||||||
return t('ai_stack.subtitle', 'Methods, tools, rules, and MCPs that power my AI workflow')
|
return t('ai_stack.subtitle', 'Methods, tools, rules, and MCPs that power my AI workflow')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const accordionItems = computed(() => [{
|
||||||
|
label: headerTitle.value,
|
||||||
|
value: 'ai-stack'
|
||||||
|
}])
|
||||||
|
|
||||||
|
const accordionUi = {
|
||||||
|
root: 'flex flex-col',
|
||||||
|
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm',
|
||||||
|
header: 'px-4 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
|
||||||
|
trigger: 'group flex-1 items-center gap-2 py-3 text-left cursor-pointer',
|
||||||
|
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
|
||||||
|
leadingIcon: 'shrink-0',
|
||||||
|
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
|
||||||
|
content: 'px-4 pb-4 pt-3 data-[state=closed]:hidden',
|
||||||
|
body: 'pt-1'
|
||||||
|
} as const
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Adopt SkillFilters transition styles for smooth group filter UX */
|
|
||||||
.filter-toggle {
|
.filter-toggle {
|
||||||
transition: transform 200ms ease, box-shadow 220ms ease, filter 220ms ease;
|
transition: transform 200ms ease, box-shadow 220ms ease, filter 220ms ease;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,86 +20,83 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- GitHub Calendar Card using UCard -->
|
<!-- GitHub Calendar Accordion -->
|
||||||
<UCard v-else-if="calendar">
|
<UAccordion v-else-if="calendar" type="single" :unmount-on-hide="false" :items="accordionItems"
|
||||||
<template #header>
|
default-value="github" :ui="accordionUi">
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
|
<template #body>
|
||||||
{{ t('portfolio.githubActivity.subtitle') }}
|
<div class="flex flex-col gap-4">
|
||||||
</h3>
|
<!-- Scrollable Graph Container (only the graph scrolls) -->
|
||||||
|
<div class="overflow-x-auto pb-4 lg:overflow-visible">
|
||||||
|
<div class="min-w-[940px] max-w-full w-fit mx-auto">
|
||||||
|
<!-- Month Labels Row -->
|
||||||
|
<div class="flex mb-2">
|
||||||
|
<div class="w-8 flex-shrink-0"></div>
|
||||||
|
<div class="flex flex-1">
|
||||||
|
<div v-for="(month, index) in monthLabels" :key="index"
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400" :style="{ flex: `0 0 ${month.width}%` }">
|
||||||
|
{{ month.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid Container -->
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<!-- Day Labels -->
|
||||||
|
<div class="flex flex-col gap-[3px] w-8 flex-shrink-0">
|
||||||
|
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1"></span>
|
||||||
|
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1">Mon</span>
|
||||||
|
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1"></span>
|
||||||
|
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1">Wed</span>
|
||||||
|
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1"></span>
|
||||||
|
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1">Fri</span>
|
||||||
|
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weeks Grid -->
|
||||||
|
<div class="flex flex-1 justify-between gap-[3px]">
|
||||||
|
<div v-for="(week, weekIndex) in calendar.weeks" :key="weekIndex" class="flex flex-col gap-[3px]">
|
||||||
|
<div v-for="(day, dayIndex) in week.contributionDays" :key="dayIndex"
|
||||||
|
class="w-[11px] h-[11px] rounded-[2px] cursor-pointer transition-transform hover:scale-110"
|
||||||
|
:class="getContributionClass(day.contributionCount)" @mouseenter="hoveredDay = day"
|
||||||
|
@mouseleave="hoveredDay = null" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer: Legend + Hover Info (always visible) -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>Less</span>
|
||||||
|
<div class="flex gap-[3px] mx-1">
|
||||||
|
<div v-for="(cls, idx) in legendClasses" :key="idx" class="w-[11px] h-[11px] rounded-[2px]"
|
||||||
|
:class="cls" />
|
||||||
|
</div>
|
||||||
|
<span>More</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="text-xs text-gray-600 dark:text-gray-300 min-h-[32px] flex flex-col justify-center text-right">
|
||||||
|
<template v-if="hoveredDay">
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ hoveredDay.contributionCount }} contribution{{ hoveredDay.contributionCount !== 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formatDateFull(hoveredDay.date) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</UAccordion>
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<!-- Scrollable Graph Container (only the graph scrolls) -->
|
|
||||||
<div class="overflow-x-auto pb-4 lg:overflow-visible">
|
|
||||||
<div class="min-w-[940px] max-w-full w-fit mx-auto">
|
|
||||||
<!-- Month Labels Row -->
|
|
||||||
<div class="flex mb-2">
|
|
||||||
<div class="w-8 flex-shrink-0"></div>
|
|
||||||
<div class="flex flex-1">
|
|
||||||
<div v-for="(month, index) in monthLabels" :key="index" class="text-xs text-gray-500 dark:text-gray-400"
|
|
||||||
:style="{ flex: `0 0 ${month.width}%` }">
|
|
||||||
{{ month.name }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Grid Container -->
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<!-- Day Labels -->
|
|
||||||
<div class="flex flex-col gap-[3px] w-8 flex-shrink-0">
|
|
||||||
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1"></span>
|
|
||||||
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1">Mon</span>
|
|
||||||
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1"></span>
|
|
||||||
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1">Wed</span>
|
|
||||||
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1"></span>
|
|
||||||
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1">Fri</span>
|
|
||||||
<span class="h-[11px] text-[11px] text-gray-500 dark:text-gray-400 text-right pr-1"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Weeks Grid -->
|
|
||||||
<div class="flex flex-1 justify-between gap-[3px]">
|
|
||||||
<div v-for="(week, weekIndex) in calendar.weeks" :key="weekIndex" class="flex flex-col gap-[3px]">
|
|
||||||
<div v-for="(day, dayIndex) in week.contributionDays" :key="dayIndex"
|
|
||||||
class="w-[11px] h-[11px] rounded-[2px] cursor-pointer transition-transform hover:scale-110"
|
|
||||||
:class="getContributionClass(day.contributionCount)" @mouseenter="hoveredDay = day"
|
|
||||||
@mouseleave="hoveredDay = null" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer: Legend + Hover Info (always visible) -->
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
<span>Less</span>
|
|
||||||
<div class="flex gap-[3px] mx-1">
|
|
||||||
<div v-for="(cls, idx) in legendClasses" :key="idx" class="w-[11px] h-[11px] rounded-[2px]"
|
|
||||||
:class="cls" />
|
|
||||||
</div>
|
|
||||||
<span>More</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="text-xs text-gray-600 dark:text-gray-300 min-h-[32px] flex flex-col justify-center text-right">
|
|
||||||
<template v-if="hoveredDay">
|
|
||||||
<span class="font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ hoveredDay.contributionCount }} contribution{{ hoveredDay.contributionCount !== 1 ? 's' : '' }}
|
|
||||||
</span>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">
|
|
||||||
{{ formatDateFull(hoveredDay.date) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import type { GitHubContributionCalendar, GitHubContributionDay } from '@/types/github'
|
import type { GitHubContributionCalendar, GitHubContributionDay } from '@/types/github'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -114,12 +111,44 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
username: 'aliarghyani'
|
username: 'aliarghyani'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Detect mobile for accordion behavior (SSR-safe)
|
||||||
|
const isMobile = ref(true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth < 768
|
||||||
|
}
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const calendar = ref<GitHubContributionCalendar | null>(null)
|
const calendar = ref<GitHubContributionCalendar | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref(false)
|
const error = ref(false)
|
||||||
const hoveredDay = ref<GitHubContributionDay | null>(null)
|
const hoveredDay = ref<GitHubContributionDay | null>(null)
|
||||||
|
|
||||||
|
// Accordion config
|
||||||
|
const accordionItems = computed(() => [{
|
||||||
|
label: t('portfolio.githubActivity.subtitle'),
|
||||||
|
value: 'github'
|
||||||
|
}])
|
||||||
|
|
||||||
|
const accordionUi = {
|
||||||
|
root: 'flex flex-col',
|
||||||
|
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm',
|
||||||
|
header: 'px-4 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
|
||||||
|
trigger: 'group flex-1 items-center gap-2 py-3 text-left cursor-pointer',
|
||||||
|
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
|
||||||
|
leadingIcon: 'shrink-0',
|
||||||
|
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
|
||||||
|
content: 'px-4 pb-4 pt-3 data-[state=closed]:hidden',
|
||||||
|
body: 'pt-1'
|
||||||
|
} as const
|
||||||
|
|
||||||
// Get contribution level class - uses CSS utility classes from main.css
|
// Get contribution level class - uses CSS utility classes from main.css
|
||||||
// that reference Nuxt UI's dynamic --ui-color-primary-* variables
|
// that reference Nuxt UI's dynamic --ui-color-primary-* variables
|
||||||
const getContributionClass = (count: number): string => {
|
const getContributionClass = (count: number): string => {
|
||||||
|
|||||||
@@ -21,9 +21,9 @@
|
|||||||
<img v-if="currentRole.companyLogo" :src="currentRole.companyLogo" :alt="`${currentRole.company} logo`"
|
<img v-if="currentRole.companyLogo" :src="currentRole.companyLogo" :alt="`${currentRole.company} logo`"
|
||||||
class="h-7 w-7 rounded-md object-contain" loading="lazy" />
|
class="h-7 w-7 rounded-md object-contain" loading="lazy" />
|
||||||
<span class="">{{ t('hero.currently') }}</span>
|
<span class="">{{ t('hero.currently') }}</span>
|
||||||
<span class="font-semibold text-primary-600 dark:text-primary-300">
|
<span class="font-semibold company-name">
|
||||||
<a v-if="currentRole.companyLink" :href="currentRole.companyLink" target="_blank" rel="noopener"
|
<a v-if="currentRole.companyLink" :href="currentRole.companyLink" target="_blank" rel="noopener"
|
||||||
class="hover:underline text-primary-600 dark:text-primary-300">
|
class="hover:underline company-name">
|
||||||
{{ currentRole.company }}
|
{{ currentRole.company }}
|
||||||
</a>
|
</a>
|
||||||
<span v-else>{{ currentRole.company }}</span>
|
<span v-else>{{ currentRole.company }}</span>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Resume Button - Desktop only -->
|
<!-- Resume Button - Desktop only -->
|
||||||
<NuxtLink to="/resume" class="hidden sm:inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold
|
<NuxtLink to="/resume" class="hidden sm:inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold
|
||||||
bg-gradient-to-r from-primary-500 via-purple-500 to-pink-500
|
bg-gradient-to-r from-primary-500 via-purple-500 to-pink-500
|
||||||
hover:from-primary-600 hover:via-purple-600 hover:to-pink-600
|
hover:from-primary-600 hover:via-purple-600 hover:to-pink-600
|
||||||
text-white rounded-full shadow-lg shadow-primary-500/25
|
text-white rounded-full shadow-lg shadow-primary-500/25
|
||||||
transition-all duration-300 hover:scale-105 hover:shadow-xl hover:shadow-primary-500/40
|
transition-all duration-300 hover:scale-105 hover:shadow-xl hover:shadow-primary-500/40
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Resume Button - Mobile only -->
|
<!-- Resume Button - Mobile only -->
|
||||||
<NuxtLink to="/resume" class="sm:hidden inline-flex items-center justify-center gap-2 mt-2 px-4 py-2 text-sm font-medium
|
<NuxtLink to="/resume" class="sm:hidden inline-flex items-center justify-center gap-2 mt-2 px-4 py-2 text-sm font-medium
|
||||||
bg-gradient-to-r from-primary-500 via-purple-500 to-pink-500
|
bg-gradient-to-r from-primary-500 via-purple-500 to-pink-500
|
||||||
hover:from-primary-600 hover:via-purple-600 hover:to-pink-600
|
hover:from-primary-600 hover:via-purple-600 hover:to-pink-600
|
||||||
text-white rounded-full shadow-lg shadow-primary-500/25
|
text-white rounded-full shadow-lg shadow-primary-500/25
|
||||||
transition-all duration-300 hover:scale-105">
|
transition-all duration-300 hover:scale-105">
|
||||||
@@ -211,3 +211,35 @@ async function copyEmail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.company-name {
|
||||||
|
color: var(--ui-color-primary-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .company-name {
|
||||||
|
color: var(--ui-color-primary-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Tailwind ring color variable for chip buttons */
|
||||||
|
:deep(.chip-button) {
|
||||||
|
--tw-ring-color: var(--ui-color-primary-500) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dark .chip-button) {
|
||||||
|
--tw-ring-color: var(--ui-color-primary-400) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Also override on hover and focus states */
|
||||||
|
:deep(.chip-button:hover),
|
||||||
|
:deep(.chip-button:focus),
|
||||||
|
:deep(.chip-button:focus-visible) {
|
||||||
|
--tw-ring-color: var(--ui-color-primary-500) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dark .chip-button:hover),
|
||||||
|
:deep(.dark .chip-button:focus),
|
||||||
|
:deep(.dark .chip-button:focus-visible) {
|
||||||
|
--tw-ring-color: var(--ui-color-primary-400) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -6,66 +6,61 @@
|
|||||||
<h2 class="section-title">{{ t('sections.language') }}</h2>
|
<h2 class="section-title">{{ t('sections.language') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UCard>
|
<UAccordion type="single" :unmount-on-hide="false" :items="accordionItems" default-value="language"
|
||||||
<div class="space-y-6">
|
:ui="accordionUi">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<template #body>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300 max-w-3xl">
|
<div class="space-y-6">
|
||||||
{{ t('languageSection.tagline') }}
|
<div class="flex justify-center">
|
||||||
</p>
|
<UBadge size="lg" color="primary" variant="soft" class="inline-flex items-center gap-2">
|
||||||
<UBadge
|
<UIcon name="simple-icons:duolingo" class="text-xl text-[#58CC02] dark:text-[#58CC02]" />
|
||||||
size="lg"
|
<span>
|
||||||
color="primary"
|
{{ t('languageSection.duolingo.label') }}: {{ t('languageSection.duolingo.value') }}
|
||||||
variant="soft"
|
</span>
|
||||||
class="inline-flex items-center gap-2"
|
</UBadge>
|
||||||
>
|
</div>
|
||||||
<UIcon
|
|
||||||
name="simple-icons:duolingo"
|
|
||||||
class="text-xl text-[#58CC02] dark:text-[#58CC02]"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{{ t('languageSection.duolingo.label') }}: {{ t('languageSection.duolingo.value') }}
|
|
||||||
</span>
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<div
|
<div v-for="item in items" :key="item.key"
|
||||||
v-for="item in items"
|
class="flex flex-col gap-2 rounded-lg border border-slate-200/80 p-3 dark:border-slate-700/60">
|
||||||
:key="item.key"
|
<div class="flex items-center gap-2">
|
||||||
class="flex flex-col gap-2 rounded-lg border border-slate-200/80 p-3 dark:border-slate-700/60"
|
<img v-if="item.iconType === 'image'" :src="item.icon" :alt="item.title"
|
||||||
>
|
class="h-6 w-6 object-contain" />
|
||||||
<div class="flex items-center gap-2">
|
<UIcon v-else :name="item.icon" class="text-lg text-primary-500 dark:text-primary-400" />
|
||||||
<img
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
v-if="item.iconType === 'image'"
|
{{ item.title }}
|
||||||
:src="item.icon"
|
</h3>
|
||||||
:alt="item.title"
|
</div>
|
||||||
class="h-6 w-6 object-contain"
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
/>
|
{{ item.description }}
|
||||||
<UIcon
|
</p>
|
||||||
v-else
|
|
||||||
:name="item.icon"
|
|
||||||
class="text-lg text-primary-500 dark:text-primary-400"
|
|
||||||
/>
|
|
||||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{{ item.title }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
{{ item.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</UCard>
|
</UAccordion>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Detect mobile for accordion behavior (SSR-safe)
|
||||||
|
const isMobile = ref(true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth < 768
|
||||||
|
}
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const items = computed(() => [
|
const items = computed(() => [
|
||||||
{
|
{
|
||||||
key: 'ielts',
|
key: 'ielts',
|
||||||
@@ -82,4 +77,21 @@ const items = computed(() => [
|
|||||||
description: t('languageSection.items.huawei.desc'),
|
description: t('languageSection.items.huawei.desc'),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const accordionItems = computed(() => [{
|
||||||
|
label: t('languageSection.accordionLabel'),
|
||||||
|
value: 'language'
|
||||||
|
}])
|
||||||
|
|
||||||
|
const accordionUi = {
|
||||||
|
root: 'flex flex-col',
|
||||||
|
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm',
|
||||||
|
header: 'px-4 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
|
||||||
|
trigger: 'group flex-1 items-center gap-2 py-3 text-left cursor-pointer',
|
||||||
|
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
|
||||||
|
leadingIcon: 'shrink-0',
|
||||||
|
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
|
||||||
|
content: 'px-4 pb-4 pt-3 data-[state=closed]:hidden',
|
||||||
|
body: 'pt-1'
|
||||||
|
} as const
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,75 +5,112 @@
|
|||||||
<UIcon name="i-twemoji-rocket" class="text-2xl" />
|
<UIcon name="i-twemoji-rocket" class="text-2xl" />
|
||||||
<h2 class="section-title text-start">{{ t('sections.projects') }}</h2>
|
<h2 class="section-title text-start">{{ t('sections.projects') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="g in nonEmptyCategoryList" :key="g.cat" class="space-y-3 mb-5">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-twemoji-open-book" class="text-xl" />
|
|
||||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{{ tCategory(g.cat) }}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<UAccordion type="single" :unmount-on-hide="false" :items="accordionItems" default-value="projects"
|
||||||
<UCard v-for="(p, i) in g.items" :key="`${g.cat}-${i}-${p.name}`"
|
:ui="accordionUi">
|
||||||
class="flex h-full flex-col border border-gray-200/60 shadow-none transition hover:-translate-y-1 hover:shadow-lg dark:border-gray-700/40">
|
<template #body>
|
||||||
<div class="flex h-full flex-col gap-4">
|
<div v-for="g in nonEmptyCategoryList" :key="g.cat" class="space-y-3 mb-5">
|
||||||
<div class="flex gap-3">
|
<div class="flex items-center gap-2">
|
||||||
<NuxtImg v-if="p.thumbnail" :src="p.thumbnail" :alt="`${p.name} logo`"
|
<UIcon name="i-twemoji-open-book" class="text-xl" />
|
||||||
class="h-12 w-12 rounded-xl border border-gray-200/70 bg-white object-cover shadow-sm dark:border-gray-700/40 dark:bg-slate-900"
|
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{{ tCategory(g.cat) }}</h3>
|
||||||
width="96" height="96" sizes="96px" format="webp" loading="lazy" />
|
|
||||||
<div v-else
|
|
||||||
class="flex h-12 w-12 items-center justify-center rounded-xl border border-gray-200/70 bg-primary-500/10 text-primary-600 shadow-sm dark:border-gray-700/40 dark:bg-primary-400/10 dark:text-primary-200">
|
|
||||||
<UIcon :name="getProjectIcon(p)" class="text-2xl" />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-1 flex-col gap-3">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{{ p.name }}</h3>
|
|
||||||
<div v-if="p.status || p.opensource" class="mt-2 flex flex-wrap items-center gap-2">
|
|
||||||
<UBadge v-if="p.status" color="primary" variant="soft" class="rounded-full capitalize">
|
|
||||||
{{ p.status }}
|
|
||||||
</UBadge>
|
|
||||||
<UBadge v-if="p.opensource" color="emerald" variant="soft" class="rounded-full">
|
|
||||||
<UIcon name="i-mdi-source-branch" class="mr-1" />
|
|
||||||
{{ t('projectLabels.openSource') }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-200">{{ p.description }}</p>
|
|
||||||
<div class="flex flex-1 flex-col gap-3 pt-1">
|
|
||||||
<div v-if="p.icons?.length"
|
|
||||||
class="flex flex-wrap items-center gap-2 text-primary-500 dark:text-primary-300">
|
|
||||||
<UIcon v-for="(ic, k) in p.icons" :key="k" :name="ic" class="text-xl" />
|
|
||||||
</div>
|
|
||||||
<div v-if="p.links?.length" class="mt-auto flex flex-wrap gap-2">
|
|
||||||
<UButton v-for="(l, j) in p.links" :key="j" :to="l.to" target="_blank" size="xs" color="primary"
|
|
||||||
variant="soft" trailing-icon="i-mdi-arrow-top-right-thin" class="hover-ring-tint"
|
|
||||||
:aria-label="l.label">
|
|
||||||
<UIcon v-if="l.icon" :name="l.icon" class="mr-1" />
|
|
||||||
{{ l.label }}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
|
||||||
</div>
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
</div>
|
<UCard v-for="(p, i) in g.items" :key="`${g.cat}-${i}-${p.name}`"
|
||||||
|
class="flex h-full flex-col border border-gray-200/60 shadow-none transition hover:-translate-y-1 hover:shadow-lg dark:border-gray-700/40">
|
||||||
|
<div class="flex h-full flex-col gap-4">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<NuxtImg v-if="p.thumbnail" :src="p.thumbnail" :alt="`${p.name} logo`"
|
||||||
|
class="h-12 w-12 rounded-xl border border-gray-200/70 bg-white object-cover shadow-sm dark:border-gray-700/40 dark:bg-slate-900"
|
||||||
|
width="96" height="96" sizes="96px" format="webp" loading="lazy" />
|
||||||
|
<div v-else
|
||||||
|
class="flex h-12 w-12 items-center justify-center rounded-xl border border-gray-200/70 bg-primary-500/10 text-primary-600 shadow-sm dark:border-gray-700/40 dark:bg-primary-400/10 dark:text-primary-200">
|
||||||
|
<UIcon :name="getProjectIcon(p)" class="text-2xl" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{{ p.name }}</h3>
|
||||||
|
<div v-if="p.status || p.opensource" class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<UBadge v-if="p.status" color="primary" variant="soft" class="rounded-full capitalize">
|
||||||
|
{{ p.status }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge v-if="p.opensource" color="emerald" variant="soft" class="rounded-full">
|
||||||
|
<UIcon name="i-mdi-source-branch" class="mr-1" />
|
||||||
|
{{ t('projectLabels.openSource') }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-200">{{ p.description }}</p>
|
||||||
|
<div class="flex flex-1 flex-col gap-3 pt-1">
|
||||||
|
<div v-if="p.icons?.length"
|
||||||
|
class="flex flex-wrap items-center gap-2 text-primary-500 dark:text-primary-300">
|
||||||
|
<UIcon v-for="(ic, k) in p.icons" :key="k" :name="ic" class="text-xl" />
|
||||||
|
</div>
|
||||||
|
<div v-if="p.links?.length" class="mt-auto flex flex-wrap gap-2">
|
||||||
|
<UButton v-for="(l, j) in p.links" :key="j" :to="l.to" target="_blank" size="xs"
|
||||||
|
color="primary" variant="soft" trailing-icon="i-mdi-arrow-top-right-thin"
|
||||||
|
class="hover-ring-tint" :aria-label="l.label">
|
||||||
|
<UIcon v-if="l.icon" :name="l.icon" class="mr-1" />
|
||||||
|
{{ l.label }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UAccordion>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { usePortfolio } from '@/composables/usePortfolio'
|
import { usePortfolio } from '@/composables/usePortfolio'
|
||||||
import type { Project } from '@/types/portfolio.types'
|
import type { Project } from '@/types/portfolio.types'
|
||||||
|
|
||||||
const portfolio = usePortfolio()
|
const portfolio = usePortfolio()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Detect mobile for accordion behavior (SSR-safe)
|
||||||
|
const isMobile = ref(true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth < 768
|
||||||
|
}
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Accordion config
|
||||||
|
const accordionItems = computed(() => [{
|
||||||
|
label: t('sections.projectsAccordion'),
|
||||||
|
value: 'projects'
|
||||||
|
}])
|
||||||
|
|
||||||
|
const accordionUi = {
|
||||||
|
root: 'flex flex-col',
|
||||||
|
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm',
|
||||||
|
header: 'px-4 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
|
||||||
|
trigger: 'group flex-1 items-center gap-2 py-3 text-left cursor-pointer',
|
||||||
|
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
|
||||||
|
leadingIcon: 'shrink-0',
|
||||||
|
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
|
||||||
|
content: 'px-4 pb-4 pt-3 data-[state=closed]:hidden',
|
||||||
|
body: 'pt-1'
|
||||||
|
} as const
|
||||||
|
|
||||||
type Category = NonNullable<Project['category']>
|
type Category = NonNullable<Project['category']>
|
||||||
const categories: Category[] = ['current', 'freelance', 'public']
|
const categories: Category[] = ['current', 'freelance', 'public']
|
||||||
|
|
||||||
const projectsByCategory = computed<Record<Category, Project[]>>(() => {
|
const projectsByCategory = computed<Record<Category, Project[]>>(() => {
|
||||||
// Initialize with all categories to preserve order and allow skipping empty ones
|
|
||||||
const acc: Record<Category, Project[]> = { current: [], freelance: [], public: [] }
|
const acc: Record<Category, Project[]> = { current: [], freelance: [], public: [] }
|
||||||
for (const p of portfolio.value.projects) {
|
for (const p of portfolio.value.projects) {
|
||||||
const cat = (p.category ?? 'freelance') as Category
|
const cat = (p.category ?? 'freelance') as Category
|
||||||
@@ -81,9 +118,11 @@ const projectsByCategory = computed<Record<Category, Project[]>>(() => {
|
|||||||
}
|
}
|
||||||
return acc
|
return acc
|
||||||
})
|
})
|
||||||
|
|
||||||
const categoryList = computed<Array<{ cat: Category; items: Project[] }>>(() => {
|
const categoryList = computed<Array<{ cat: Category; items: Project[] }>>(() => {
|
||||||
return categories.map((c) => ({ cat: c as Category, items: projectsByCategory.value[c as Category] }))
|
return categories.map((c) => ({ cat: c as Category, items: projectsByCategory.value[c as Category] }))
|
||||||
})
|
})
|
||||||
|
|
||||||
const nonEmptyCategoryList = computed<Array<{ cat: Category; items: Project[] }>>(() => {
|
const nonEmptyCategoryList = computed<Array<{ cat: Category; items: Project[] }>>(() => {
|
||||||
return categoryList.value.filter(g => g.items.length > 0)
|
return categoryList.value.filter(g => g.items.length > 0)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UAccordion type="multiple" :unmount-on-hide="false" :items="skillSections" :default-value="openSkillSections"
|
<UAccordion type="multiple" :unmount-on-hide="false" :items="skillSections" :default-value="openSkillSections"
|
||||||
:ui="accordionUi">
|
:ui="accordionUi" :disabled="!isMobile">
|
||||||
<template #leading="{ item }">
|
<template #leading="{ item }">
|
||||||
<UIcon v-if="item.icon" :name="item.icon" class="text-base text-primary-500 dark:text-primary-300" />
|
<UIcon v-if="item.icon" :name="item.icon" class="text-base text-primary-500 dark:text-primary-300" />
|
||||||
</template>
|
</template>
|
||||||
@@ -23,13 +23,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import type { Tag, SkillType } from '@/types/portfolio.types'
|
import type { Tag, SkillType } from '@/types/portfolio.types'
|
||||||
import { expert, proficient, usedBefore } from '@/data/skills'
|
import { expert, proficient, usedBefore } from '@/data/skills'
|
||||||
import SkillGrid from '@/components/portfolio/SkillGrid.vue'
|
import SkillGrid from '@/components/portfolio/SkillGrid.vue'
|
||||||
import SkillFilters from '@/components/portfolio/SkillFilters.vue'
|
import SkillFilters from '@/components/portfolio/SkillFilters.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Detect mobile for accordion behavior (SSR-safe)
|
||||||
|
const isMobile = ref(true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth < 768
|
||||||
|
}
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
type SkillSectionKey = 'expert' | 'proficient' | 'usedBefore'
|
type SkillSectionKey = 'expert' | 'proficient' | 'usedBefore'
|
||||||
|
|
||||||
// Multi-select filters for Skill types
|
// Multi-select filters for Skill types
|
||||||
@@ -61,11 +76,11 @@ const openSkillSections = computed(() => skillSections.value.map(section => sect
|
|||||||
const accordionUi = {
|
const accordionUi = {
|
||||||
root: 'flex flex-col gap-3 md:grid md:grid-cols-3 md:gap-4 md:items-stretch',
|
root: 'flex flex-col gap-3 md:grid md:grid-cols-3 md:gap-4 md:items-stretch',
|
||||||
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm md:self-stretch data-[state=closed]:md:self-start md:h-full data-[state=open]:md:h-full data-[state=closed]:md:h-auto data-[state=open]:md:min-h-[320px] data-[state=closed]:md:min-h-[64px]',
|
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm md:self-stretch data-[state=closed]:md:self-start md:h-full data-[state=open]:md:h-full data-[state=closed]:md:h-auto data-[state=open]:md:min-h-[320px] data-[state=closed]:md:min-h-[64px]',
|
||||||
header: 'px-4',
|
header: 'px-4 border-b border-gray-200/70 dark:border-gray-700/50',
|
||||||
trigger: 'group flex-1 items-center gap-2 py-3 text-left',
|
trigger: 'group flex-1 items-center gap-2 py-3 text-left md:cursor-default',
|
||||||
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
|
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
|
||||||
leadingIcon: 'shrink-0',
|
leadingIcon: 'shrink-0',
|
||||||
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
|
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180 md:hidden',
|
||||||
content: 'px-4 pb-4 pt-1 data-[state=closed]:hidden',
|
content: 'px-4 pb-4 pt-1 data-[state=closed]:hidden',
|
||||||
body: 'pt-1'
|
body: 'pt-1'
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -6,25 +6,31 @@
|
|||||||
<h2 class="section-title">{{ t('sections.softSkills') }}</h2>
|
<h2 class="section-title">{{ t('sections.softSkills') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UCard>
|
<UAccordion type="single" :unmount-on-hide="false" :items="accordionItems" default-value="soft-skills"
|
||||||
<div class="flex flex-wrap gap-1.5">
|
:ui="accordionUi">
|
||||||
<template v-for="(s, i) in resolved" :key="s.key">
|
<template #body>
|
||||||
<UTooltip :arrow="true" :delay-duration="0.5" :text="s.description">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
<UBadge variant="soft" class="chip-base select-none" :class="chipClass(i)">
|
<template v-for="(s, i) in resolved" :key="s.key">
|
||||||
<span class="inline-flex items-center gap-1.5">
|
<UTooltip :arrow="true" :delay-duration="0.5" :text="s.description">
|
||||||
<UIcon :name="s.icon" class="text-sm" />
|
<UBadge variant="soft" class="chip-base select-none" :class="chipClass(i)">
|
||||||
<span class="text-xs">{{ s.label }}</span>
|
<span class="inline-flex items-center gap-1.5">
|
||||||
</span>
|
<UIcon :name="s.icon" class="text-sm" />
|
||||||
</UBadge>
|
<span class="text-xs">{{ s.label }}</span>
|
||||||
</UTooltip>
|
</span>
|
||||||
</template>
|
</UBadge>
|
||||||
</div>
|
</UTooltip>
|
||||||
</UCard>
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UAccordion>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { chipTones } from '@/utils/chipTones'
|
||||||
|
|
||||||
type SoftKey =
|
type SoftKey =
|
||||||
| 'problemSolving'
|
| 'problemSolving'
|
||||||
| 'attentionToDetail'
|
| 'attentionToDetail'
|
||||||
@@ -36,7 +42,21 @@ type SoftKey =
|
|||||||
| 'projectManagement'
|
| 'projectManagement'
|
||||||
| 'adaptability'
|
| 'adaptability'
|
||||||
|
|
||||||
import { chipTones } from '@/utils/chipTones'
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Detect mobile for accordion behavior (SSR-safe)
|
||||||
|
const isMobile = ref(true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth < 768
|
||||||
|
}
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const base: { key: SoftKey; icon: string }[] = [
|
const base: { key: SoftKey; icon: string }[] = [
|
||||||
{ key: 'problemSolving', icon: 'i-twemoji-light-bulb' },
|
{ key: 'problemSolving', icon: 'i-twemoji-light-bulb' },
|
||||||
@@ -50,7 +70,6 @@ const base: { key: SoftKey; icon: string }[] = [
|
|||||||
{ key: 'adaptability', icon: 'i-twemoji-counterclockwise-arrows-button' },
|
{ key: 'adaptability', icon: 'i-twemoji-counterclockwise-arrows-button' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const resolved = computed(() =>
|
const resolved = computed(() =>
|
||||||
base.map(s => ({
|
base.map(s => ({
|
||||||
...s,
|
...s,
|
||||||
@@ -60,4 +79,21 @@ const resolved = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const chipClass = (i: number) => chipTones[i % chipTones.length]
|
const chipClass = (i: number) => chipTones[i % chipTones.length]
|
||||||
|
|
||||||
|
const accordionItems = computed(() => [{
|
||||||
|
label: t('softSkillsAccordion.label'),
|
||||||
|
value: 'soft-skills'
|
||||||
|
}])
|
||||||
|
|
||||||
|
const accordionUi = {
|
||||||
|
root: 'flex flex-col',
|
||||||
|
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm',
|
||||||
|
header: 'px-4 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
|
||||||
|
trigger: 'group flex-1 items-center gap-2 py-3 text-left cursor-pointer',
|
||||||
|
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
|
||||||
|
leadingIcon: 'shrink-0',
|
||||||
|
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
|
||||||
|
content: 'px-4 pb-4 pt-3 data-[state=closed]:hidden',
|
||||||
|
body: 'pt-1'
|
||||||
|
} as const
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,34 +5,39 @@
|
|||||||
<UIcon name="i-twemoji-briefcase" class="text-2xl" />
|
<UIcon name="i-twemoji-briefcase" class="text-2xl" />
|
||||||
<h2 class="section-title">{{ t('sections.work') }}</h2>
|
<h2 class="section-title">{{ t('sections.work') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<UTimeline :items="experiences" :default-value="0" color="primary" size="md" class="max-w-3xl">
|
|
||||||
<template #indicator="{ item }">
|
<UAccordion type="single" :unmount-on-hide="false" :items="accordionItems" default-value="work" :ui="accordionUi">
|
||||||
<img v-if="item.logo" :src="item.logo" :alt="`${item.company} logo`"
|
<template #body>
|
||||||
class="h-10 w-10 object-contain" loading="lazy" />
|
<UTimeline :items="experiences" :default-value="0" color="primary" size="md" class="max-w-3xl">
|
||||||
|
<template #indicator="{ item }">
|
||||||
|
<img v-if="item.logo" :src="item.logo" :alt="`${item.company} logo`" class="h-10 w-10 object-contain"
|
||||||
|
loading="lazy" />
|
||||||
|
</template>
|
||||||
|
<template #title="{ item }">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="font-semibold">{{ item.title }}</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{ item.company }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #description="{ item }">
|
||||||
|
<ul v-if="item.descriptions?.length"
|
||||||
|
class="mt-2 list-disc space-y-1 text-sm text-gray-700 dark:text-gray-300 pl-5">
|
||||||
|
<li v-for="(desc, i) in item.descriptions" :key="i">{{ desc }}</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="item.icons?.length" class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<UIcon v-for="(icon, i) in item.icons" :key="i" :name="icon"
|
||||||
|
class="text-xl text-primary-500 dark:text-primary-400" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UTimeline>
|
||||||
</template>
|
</template>
|
||||||
<template #title="{ item }">
|
</UAccordion>
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="font-semibold">{{ item.title }}</span>
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ item.company }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #description="{ item }">
|
|
||||||
<ul v-if="item.descriptions?.length"
|
|
||||||
class="mt-2 list-disc space-y-1 text-sm text-gray-700 dark:text-gray-300 pl-5">
|
|
||||||
<li v-for="(desc, i) in item.descriptions" :key="i">{{ desc }}</li>
|
|
||||||
</ul>
|
|
||||||
<div v-if="item.icons?.length" class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<UIcon v-for="(icon, i) in item.icons" :key="i" :name="icon"
|
|
||||||
class="text-xl text-primary-500 dark:text-primary-400" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UTimeline>
|
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { usePortfolio } from '@/composables/usePortfolio'
|
import { usePortfolio } from '@/composables/usePortfolio'
|
||||||
import type { TimelineItem } from '@nuxt/ui'
|
import type { TimelineItem } from '@nuxt/ui'
|
||||||
import type { CompanyExperience, Experience } from '@/types/portfolio.types'
|
import type { CompanyExperience, Experience } from '@/types/portfolio.types'
|
||||||
@@ -41,6 +46,38 @@ const { t } = useI18n()
|
|||||||
const portfolio = usePortfolio()
|
const portfolio = usePortfolio()
|
||||||
const presentText = computed(() => t('common.present'))
|
const presentText = computed(() => t('common.present'))
|
||||||
|
|
||||||
|
// Detect mobile for accordion behavior (SSR-safe)
|
||||||
|
const isMobile = ref(true)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const checkMobile = () => {
|
||||||
|
isMobile.value = window.innerWidth < 768
|
||||||
|
}
|
||||||
|
checkMobile()
|
||||||
|
window.addEventListener('resize', checkMobile)
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', checkMobile)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Accordion config
|
||||||
|
const accordionItems = computed(() => [{
|
||||||
|
label: t('sections.workAccordion'),
|
||||||
|
value: 'work'
|
||||||
|
}])
|
||||||
|
|
||||||
|
const accordionUi = {
|
||||||
|
root: 'flex flex-col',
|
||||||
|
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm',
|
||||||
|
header: 'px-4 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
|
||||||
|
trigger: 'group flex-1 items-center gap-2 py-3 text-left cursor-pointer',
|
||||||
|
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
|
||||||
|
leadingIcon: 'shrink-0',
|
||||||
|
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
|
||||||
|
content: 'px-4 pb-4 pt-3 data-[state=closed]:hidden',
|
||||||
|
body: 'pt-1'
|
||||||
|
} as const
|
||||||
|
|
||||||
type RichTimelineItem = TimelineItem & {
|
type RichTimelineItem = TimelineItem & {
|
||||||
company: string
|
company: string
|
||||||
descriptions?: string[]
|
descriptions?: string[]
|
||||||
@@ -54,7 +91,6 @@ const experiences = computed<RichTimelineItem[]>(() => {
|
|||||||
|
|
||||||
list.forEach((exp: any, index: number) => {
|
list.forEach((exp: any, index: number) => {
|
||||||
if (Array.isArray(exp.positions)) {
|
if (Array.isArray(exp.positions)) {
|
||||||
// CompanyExperience with multiple positions
|
|
||||||
const company = exp as CompanyExperience
|
const company = exp as CompanyExperience
|
||||||
company.positions.forEach((pos, posIndex) => {
|
company.positions.forEach((pos, posIndex) => {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -69,7 +105,6 @@ const experiences = computed<RichTimelineItem[]>(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Single Experience
|
|
||||||
const single = exp as Experience
|
const single = exp as Experience
|
||||||
items.push({
|
items.push({
|
||||||
date: `${single.start}${single.ongoing ? ` - ${presentText.value}` : single.end ? ` - ${single.end}` : ''}`,
|
date: `${single.start}${single.ongoing ? ` - ${presentText.value}` : single.end ? ` - ${single.end}` : ''}`,
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ if (post.value) {
|
|||||||
<ContentRenderer v-if="(post as any).body" :value="(post as any).body" />
|
<ContentRenderer v-if="(post as any).body" :value="(post as any).body" />
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<!-- Share Buttons -->
|
||||||
|
<BlogShare :title="(post as any).title" :url="`${siteUrl}${(post as any).path}`" />
|
||||||
|
|
||||||
<!-- Blog Navigation (Prev/Next) -->
|
<!-- Blog Navigation (Prev/Next) -->
|
||||||
<BlogNavigation :prev="prevPost" :next="nextPost" />
|
<BlogNavigation :prev="prevPost" :next="nextPost" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
title: "A Big Career Change: From 7 Years at Huawei to Frontend Development"
|
||||||
|
description: "A personal story of leaving telecom and team management at Huawei to pursue frontend development, covering the challenges, lessons, and mental shifts required for a successful career transition."
|
||||||
|
date: "2025-11-25"
|
||||||
|
tags: ["career-change", "frontend-development", "personal-growth", "huawei", "developer-journey"]
|
||||||
|
author: "Ali Arghyani"
|
||||||
|
image: "/img/blog/big-career-change.webp"
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
When I left the world of telecom and team management—and the years I had spent at Huawei—to move into software development, I didn't have a clear picture of the future.
|
||||||
|
|
||||||
|
I only knew one thing: I wanted a life and a professional identity that actually felt aligned with who I am.
|
||||||
|
|
||||||
|
But that turning point didn't come out of nowhere. The years before that were their own kind of school.
|
||||||
|
|
||||||
|
## What Huawei Built in Me
|
||||||
|
|
||||||
|
Huawei was where I learned what it really means to work inside a large organization—not from the outside, but from the middle of the system.
|
||||||
|
|
||||||
|
For the first time, I truly understood:
|
||||||
|
|
||||||
|
- how departments and hierarchies work,
|
||||||
|
- how to communicate with different levels of management,
|
||||||
|
- and how serious "customer centricity" can be when your main customer is a company like MTN Irancell.
|
||||||
|
|
||||||
|
We had endless meetings about customer performance, SLAs, and relationship management.
|
||||||
|
|
||||||
|
Inter-organizational behavior wasn't something I was naturally good at. I made a lot of clumsy mistakes in the beginning.
|
||||||
|
|
||||||
|
But senior managers—people with decades of experience—would point out small things that later became principles I built my own work on.
|
||||||
|
|
||||||
|
I learned that:
|
||||||
|
|
||||||
|
- **Reporting isn't a formality; it's the backbone of trust.**
|
||||||
|
- **Presenting your work is a skill**—being clear, honest, and precise matters.
|
||||||
|
- As long as everything was working, nobody bothered you. But a single wrong number in a report could trigger calls, meetings, and investigations from multiple sides to understand exactly what went wrong.
|
||||||
|
|
||||||
|
That experience burned one lesson into my brain: **before I send anything upwards, I need to test it and check it myself.**
|
||||||
|
|
||||||
|
Later, this became crucial in my coding mindset: the code I write shouldn't just "work once". It should be something I can come back to months later and still trust.
|
||||||
|
|
||||||
|
On top of that, combining **soft skills** (communication with managers, teamwork, handling stress, taking ownership) with **hard skills** like data analysis, Python, and Excel gave my growth a structure.
|
||||||
|
|
||||||
|
It wasn't random anymore; it became intentional.
|
||||||
|
|
||||||
|
That's why, when I joined a professional team like NexaPortal, I didn't feel like "a nobody starting from zero".
|
||||||
|
|
||||||
|
I could present myself properly, bring the values I had developed at Huawei into the team, and clearly see how I could contribute to the product and the business.
|
||||||
|
|
||||||
|
## Starting Over—Where the Path Gets Scary
|
||||||
|
|
||||||
|
Even with all of that behind me, changing careers was still scary.
|
||||||
|
|
||||||
|
Those first days felt like walking in the fog.
|
||||||
|
|
||||||
|
I was leaving an environment where everything was defined: the org chart, the salary, the responsibilities… and stepping into a field with no guarantees.
|
||||||
|
|
||||||
|
My income became unstable. Managing my finances turned into a real headache at times.
|
||||||
|
|
||||||
|
At the same time, I had to start learning from scratch, take small projects, fail, rebuild, and slowly understand what "real growth" actually looks like.
|
||||||
|
|
||||||
|
There was one thing that helped me from the inside: **I kept going.**
|
||||||
|
|
||||||
|
Not with hype. Not with motivational quotes.
|
||||||
|
|
||||||
|
With quiet, sometimes tired, consistency.
|
||||||
|
|
||||||
|
At some point I realized: this is the real "secret"—**keep going even when you don't feel like it. Even when you're scared. Even when nothing is clear yet.**
|
||||||
|
|
||||||
|
::pull-quote{highlight}
|
||||||
|
Keep going even when you don't feel like it. Even when you're scared. Even when nothing is clear yet.
|
||||||
|
::
|
||||||
|
|
||||||
|
## Mental Freeze and the Turning Point
|
||||||
|
|
||||||
|
One of the hardest challenges I faced wasn't technical at all.
|
||||||
|
|
||||||
|
It was about having the courage to show up. To present. To let myself be seen.
|
||||||
|
|
||||||
|
Every time I wanted to present something, demo a feature, or explain something to a team, my mind would freeze.
|
||||||
|
|
||||||
|
It was strange. I had led teams before, but now, in this new role, I felt like a beginner who suddenly forgets how to speak.
|
||||||
|
|
||||||
|
Eventually I understood why: a big part of me was still attached to my old identity.
|
||||||
|
|
||||||
|
I was still mentally sitting in my previous safe zone, where my role was already defined and respected. Now I had to redefine myself from scratch.
|
||||||
|
|
||||||
|
The real turning point was when I allowed myself to be free—free from the perks, the comfort, and the sense of security that came with my previous path.
|
||||||
|
|
||||||
|
That decision unlocked something in my mind.
|
||||||
|
|
||||||
|
I stopped feeling like a piece of ice floating in a cold ocean… and started to feel like a swimmer who chooses to dive into warmer, but deeper, waters.
|
||||||
|
|
||||||
|
**That change wasn't just technical. It was about who I am.**
|
||||||
|
|
||||||
|
::callout{type="tip" title="Key Insight"}
|
||||||
|
Career change isn't just about learning new skills—it's about redefining your identity and finding alignment between who you are and what you do.
|
||||||
|
::
|
||||||
|
|
||||||
|
## The Tools I Collected Along the Way
|
||||||
|
|
||||||
|
I wasn't alone on this path.
|
||||||
|
|
||||||
|
We all carry some kind of "toolkit" with us. Mine looked like this:
|
||||||
|
|
||||||
|
- **People** who stayed close and didn't let me burn out under pressure.
|
||||||
|
- **A program called "Aknoon"** that helped me build mental and emotional structure in the middle of chaos.
|
||||||
|
- **Books** like *The Art of Thinking Clearly* and *The Compound Effect* that gave me a deeper understanding of decision-making and consistency.
|
||||||
|
- And most importantly: **the habit of slow refuelling.**
|
||||||
|
|
||||||
|
I made a commitment to myself not to burn out.
|
||||||
|
|
||||||
|
Whenever I felt my energy dropping to zero, I pulled over, refuelled, and came back.
|
||||||
|
|
||||||
|
No hero narrative. Just a smart system trying not to destroy itself.
|
||||||
|
|
||||||
|
Those small daily acts of consistency are what kept me from quitting—especially on days when, from the outside, it looked like "nothing special" was happening.
|
||||||
|
|
||||||
|
## Challenges That Made Me Go Deeper
|
||||||
|
|
||||||
|
Career change is not just about learning a new skill. **It's about changing the angle from which you look at life and work.**
|
||||||
|
|
||||||
|
This path forced me to:
|
||||||
|
|
||||||
|
- think more honestly and realistically,
|
||||||
|
- face hard realities, not just dreams,
|
||||||
|
- live with failures, delays, and financial pressure,
|
||||||
|
- take personal development more seriously,
|
||||||
|
- become stronger not only for myself, but also for the people around me,
|
||||||
|
- and, most importantly, **take full responsibility for my own growth.**
|
||||||
|
|
||||||
|
None of these were simple. Each one opened a new layer of my mind and personality.
|
||||||
|
|
||||||
|
I started to taste what "results" really feel like—and I realized that if I want bigger outcomes, I don't just need to move faster; **I need to go deeper.**
|
||||||
|
|
||||||
|
## Where I Stand Today
|
||||||
|
|
||||||
|
Today, when I work as a frontend developer—building products, collaborating with a team, and planning for the future—I feel something much clearer than before:
|
||||||
|
|
||||||
|
**I can create value.**
|
||||||
|
|
||||||
|
Not just write code. Not just close tickets.
|
||||||
|
|
||||||
|
But:
|
||||||
|
|
||||||
|
- influence the product,
|
||||||
|
- improve the user experience,
|
||||||
|
- show up honestly inside the team,
|
||||||
|
- and build a balance between quality, mental health, and long-term consistency.
|
||||||
|
|
||||||
|
Everything I learned at Huawei—the discipline of reporting, customer centricity, cross-team communication, data analysis with Python and Excel—is now woven into my work in frontend.
|
||||||
|
|
||||||
|
That integration helped me, when I joined NexaPortal, not to be "just another developer", but someone who can translate previous experience into today's context and create real value for the product, team, and business.
|
||||||
|
|
||||||
|
I'm hopeful about the future.
|
||||||
|
|
||||||
|
I know that when I come back to this article five years from now, I'll both smile and feel a deeper sense of maturity.
|
||||||
|
|
||||||
|
Because I know one thing: this path is still unfolding.
|
||||||
|
|
||||||
|
And most importantly: **I'm finally enjoying the path itself**—something I had been chasing for years: to enjoy the journey, keep going, and stay true to myself.
|
||||||
|
|
||||||
|
## A Final Note
|
||||||
|
|
||||||
|
For me, this is not just a story. It's a personal roadmap that shows where I started, what I went through, and how I think today.
|
||||||
|
|
||||||
|
If I keep going—with consistency, depth, and authenticity—I'm confident I'll be in a much better place a few years from now.
|
||||||
|
|
||||||
|
And if you're currently stuck between the fear of losing your old security and the desire to build your own path, maybe this text has just one simple message for you:
|
||||||
|
|
||||||
|
::pull-quote{highlight}
|
||||||
|
Go slower, go deeper—but keep going.
|
||||||
|
::
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
---
|
||||||
|
title: "داستان کریرچنج من از تیم لید هواوی به برنامهنویسی بعد از ۷ سال سابقه"
|
||||||
|
description: "داستان شخصی من از ترک دنیای تلکام و مدیریت تیم در هواوی و ورود به دنیای برنامهنویسی فرانتاند، چالشها، درسها و تغییرات ذهنی لازم برای یک تغییر شغلی موفق."
|
||||||
|
date: "2025-11-25"
|
||||||
|
tags: ["تغییر-شغلی", "برنامه-نویسی-فرانت-اند", "رشد-شخصی", "هواوی", "مسیر-توسعه-دهنده"]
|
||||||
|
author: "علی ارغیانی"
|
||||||
|
image: "/img/blog/big-career-change.webp"
|
||||||
|
draft: false
|
||||||
|
---
|
||||||
|
|
||||||
|
وقتی مسیرم رو عوض کردم و از دنیای تلکام و مدیریت تیم توی هواوی اومدم بیرون و وارد دنیای برنامهنویسی شدم، تصویر واضحی از آینده نداشتم.
|
||||||
|
|
||||||
|
فقط یه چیز رو میدونستم: باید به جایی برسم که زندگی و هویت حرفهایم با خودِ واقعیم هماهنگ باشه.
|
||||||
|
|
||||||
|
اما این نقطه چرخش، از یه خلأ شروع نشد؛ سالهای قبل خودش یه مدرسه بود.
|
||||||
|
|
||||||
|
## هواوی چی توی من ساخت؟
|
||||||
|
|
||||||
|
من توی هواوی با «کار کردن توی یه سازمان واقعی» آشنا شدم؛ نه از دور، از نزدیک و وسط ماجرا.
|
||||||
|
|
||||||
|
اونجا برای اولین بار، سلسلهمراتب دپارتمانها، مدیرهای لایهلایه، و حساسیت ارتباط حرفهای با مدیران بالادستی رو با پوست و استخوان فهمیدم. مشتری ما ایرانسل بود و تقریباً همه چیز حول یه کلمه میچرخید: اهمیت مشتری.
|
||||||
|
|
||||||
|
جلسات بیپایان درباره مدیریت ارتباط با مشتری، هماهنگی بین تیم ما، مشتری و بقیه استیکهولدرها.
|
||||||
|
|
||||||
|
رفتار بینسازمانی چیزی نبود که از روز اول بلد باشم؛ بارها خام و ناپخته عمل کردم.
|
||||||
|
|
||||||
|
ولی مدیران خیلی ارشد نکتههایی بهم میگفتن که کمکم تبدیل شدن به «اصول شخصی کار» من.
|
||||||
|
|
||||||
|
اونجا یاد گرفتم:
|
||||||
|
|
||||||
|
- **گزارش و ریپورتدادن فقط یه کار اداری نیست؛ قلب اعتمادِ.**
|
||||||
|
- **ارائه دادن یعنی بتونی کار انجامشده رو شفاف، دقیق و بدون حاشیه منتقل کنی.**
|
||||||
|
- تا وقتی همه چیز درست بود، کسی کاری باهات نداشت؛ اما اگه فقط یه عدد توی یه گزارش اشتباه میشد، از چند جهت تماس، جلسه و بررسی شروع میشد تا معلوم بشه چرا این خطا اتفاق افتاده.
|
||||||
|
|
||||||
|
این تجربهها یه چیز رو توی من حک کرد: **قبل از اینکه چیزی رو «بفرستم بالا»، خودم باید اونو جدی تست و چک کنم.**
|
||||||
|
|
||||||
|
بعدتر، همین ذهنیت توی کدنویسی برام حیاتی شد. اینکه کیفیت کدی که مینویسم باید جوری باشه که خودم هم چند ماه بعد بتونم با خیال راحت برگردم سراغش و دوباره ازش استفاده کنم.
|
||||||
|
|
||||||
|
از اون طرف، ترکیب سافتاسکیلها (ارتباط با مدیران، کار تیمی، مدیریت استرس، مسئولیتپذیری) با هارد اسکیلهایی مثل تحلیل دیتا، کار با پایتون و اکسل، باعث شد رشد من توی مسیر جدید بینظم و تصادفی نباشه؛ بلکه سازمانیافته جلو بره.
|
||||||
|
|
||||||
|
برای همین وقتی وارد یه تیم حرفهای مثل NexaPortal شدم، احساس نکردم «هیچکس نیستم». میتونستم خودم رو درست معرفی کنم، ارزشهایی که از هواوی و سالهای قبلی با خودم آورده بودم رو به تیم منتقل کنم و بفهمم دقیقاً کجا میتونم برای محصول و تیم مفید باشم.
|
||||||
|
|
||||||
|
## شروع دوباره؛ جایی که مسیر ترسناک میشه
|
||||||
|
|
||||||
|
با وجود همه این تجربهها، کریرچنج واقعاً ترسناک بود.
|
||||||
|
|
||||||
|
روزهای اول شبیه راهرفتن توی مه بود. از محیطی میاومدم که ساختارش مشخص بود، حقوق هر ماه میاومد، نقشها تعریف شده بود.
|
||||||
|
|
||||||
|
وارد حوزهای میشدم که هیچ تضمینی نداشت. درآمدم ثبات نداشت، دورههایی مدیریت مالی تبدیل به یه کابوس واقعی شده بود. همزمان باید از صفر یاد میگرفتم، پروژههای کوچک میگرفتم، شکست میخوردم، دوباره میساختم و تازه میفهمیدم «رشد واقعی» یعنی چی.
|
||||||
|
|
||||||
|
یه چیز اما از درون کمکم کرد: **ادامه دادن.**
|
||||||
|
|
||||||
|
نه با هیجانهای لحظهای. نه با موج انگیزه شبکههای اجتماعی.
|
||||||
|
|
||||||
|
با یه استمرار آروم و گاهی خسته، اما ادامهدار.
|
||||||
|
|
||||||
|
جایی وسط همین مسیر فهمیدم راز کار همینه: **ادامه بده، حتی وقتی حسش نیست. حتی وقتی هنوز میترسی. حتی وقتی هیچ چیز مشخص نیست.**
|
||||||
|
|
||||||
|
::pull-quote{highlight}
|
||||||
|
ادامه بده، حتی وقتی حسش نیست. حتی وقتی هنوز میترسی. حتی وقتی هیچ چیز مشخص نیست.
|
||||||
|
::
|
||||||
|
|
||||||
|
## یخزدگی ذهنی و نقطه چرخش
|
||||||
|
|
||||||
|
یکی از سختترین چالشهای من اصلاً فنی نبود.
|
||||||
|
|
||||||
|
موضوع «شجاعت ارائه دادن» بود. شجاعت اینکه دیده بشم.
|
||||||
|
|
||||||
|
هر بار میخواستم چیزی ارائه کنم، دمو بدم یا به تیمی توضیح بدم، انگار ذهنم یخ میزد.
|
||||||
|
|
||||||
|
عجیب بود؛ سالها تیم هدایت کرده بودم، اما اینجا، توی نقش جدید، دوباره مثل تازهکارها قفل میکردم.
|
||||||
|
|
||||||
|
کمکم فهمیدم چرا: هنوز درگیر هویت قدیمی بودم.
|
||||||
|
|
||||||
|
هنوز توی فضای امن قبلی گیر کرده بودم؛ فضایی که توش «تعریفشده» بودم و حالا باید از نو خودم رو تعریف میکردم.
|
||||||
|
|
||||||
|
نقطه واقعی چرخش زمانی بود که به خودم اجازه آزادی دادم؛ آزادی جدا شدن از تمام مزایا، امکانات و امنیتهایی که مسیر قبلیم داشت.
|
||||||
|
|
||||||
|
این تصمیم، چیزهایی رو توی ذهنم آزاد کرد.
|
||||||
|
|
||||||
|
حس میکردم از یه قطعه یخ شناور توی اقیانوسی سرد… تبدیل شدم به یه شناگری که خودش انتخاب میکنه توی آبی گرمتر، اما عمیقتر شنا کنه.
|
||||||
|
|
||||||
|
**این تغییر فقط فنی نبود؛ هویتی بود.**
|
||||||
|
|
||||||
|
::callout{type="tip" title="نکته کلیدی"}
|
||||||
|
کریرچنج فقط یادگیری مهارت جدید نیست—بلکه بازتعریف هویت و پیدا کردن هماهنگی بین خودِ واقعی و کاری که انجام میدهی است.
|
||||||
|
::
|
||||||
|
|
||||||
|
## توشه مسیر: آدمها، برنامهها و کتابهایی که من رو ساختن
|
||||||
|
|
||||||
|
توی این سالها، تنها نبودم.
|
||||||
|
|
||||||
|
هر کسی برای مسیرش یه توشه داره؛ توشه من اینها بودن:
|
||||||
|
|
||||||
|
- **آدمهایی** که کنارم بودن و کمک کردن زیر فشار، فرسوده نشم.
|
||||||
|
- **برنامه «اکنون»** که نظم ذهنی و احساسیم رو تقویت کرد و کمک کرد وسط شلوغی، خودم رو گم نکنم.
|
||||||
|
- **کتابهایی** مثل *تفکر شفاف* و *اثر مرکب* که نگاهم به تصمیمگیری، صبر و استمرار رو عمیقتر کردن.
|
||||||
|
- و مهمتر از همه: **عادت سوختگیری آروم.**
|
||||||
|
|
||||||
|
من سعی کردم قهرمانبازی درنیارم. هر وقت میدیدم باتریم خالی شده، کنار میکشیدم، سوختگیری میکردم و برمیگشتم.
|
||||||
|
|
||||||
|
نه با شعار؛ مثل یه سیستم هوشمند که میدونه اگه نسوزه، میتونه مسیر طولانیتری رو بره.
|
||||||
|
|
||||||
|
همین استمرار کوچک روزانه باعث شد جا نزنم؛ حتی توی روزهایی که از بیرون «هیچ اتفاق خاصی» دیده نمیشد.
|
||||||
|
|
||||||
|
## چالشهایی که من رو عمیقتر کردن
|
||||||
|
|
||||||
|
کریرچنج فقط یاد گرفتن یه مهارت جدید نیست؛ **تغییر زاویه دیده.**
|
||||||
|
|
||||||
|
این مسیر من رو مجبور کرد که:
|
||||||
|
|
||||||
|
- واقعیتر و شفافتر فکر کنم؛
|
||||||
|
- واقعیتهای سخت رو ببینم، نه فقط رویاها رو؛
|
||||||
|
- با شکست، تأخیر و فشار مالی کنار بیام؛
|
||||||
|
- روی توسعه فردی جدیتر کار کنم؛
|
||||||
|
- کنار آدمهای اطرافم قویتر باشم، نه فقط مشغول خودم؛
|
||||||
|
- و مهمتر از همه: **مسئولیت رشد خودم رو کامل بپذیرم.**
|
||||||
|
|
||||||
|
اینها اتفاقات سادهای نبودن؛ هر کدوم، لایهای از شخصیت و ذهنم رو باز کردن.
|
||||||
|
|
||||||
|
طعم نتیجه گرفتن برام واقعیتر شد و فهمیدم برای نتایج بزرگتر، باید عمیقتر بشم؛ نه فقط سریعتر.
|
||||||
|
|
||||||
|
## امروز کجا ایستادم؟
|
||||||
|
|
||||||
|
امروز، وقتی بهعنوان یه فرانتاند دولوپر روی پروژهها کار میکنم، محصول میسازم، با تیم هماهنگ میشم و برای آینده برنامهریزی میکنم، یه چیز رو واضحتر از همیشه حس میکنم:
|
||||||
|
|
||||||
|
**میتونم ارزش خلق کنم.**
|
||||||
|
|
||||||
|
نه فقط کد بنویسم. نه فقط یه تسک رو تحویل بدم.
|
||||||
|
|
||||||
|
بلکه:
|
||||||
|
|
||||||
|
- روی محصول اثر بذارم،
|
||||||
|
- تجربه کاربر رو بهتر کنم،
|
||||||
|
- توی تیم، حضور واقعی و صادقانه داشته باشم،
|
||||||
|
- و بین کیفیت کار، سلامت روان و استمرار، تعادل بسازم.
|
||||||
|
|
||||||
|
همه اون چیزی که از هواوی، از کار تیمی، از گزارشدادن دقیق، از اهمیت مشتری، و از سالهای تحلیل دیتا و کار با پایتون و اکسل یاد گرفته بودم، امروز با مسیر جدیدم توی فرانتاند گره خورده.
|
||||||
|
|
||||||
|
این یکپارچگی کمکم کرد وقتی وارد NexaPortal شدم، فقط «یه برنامهنویس جدید» نباشم؛ بلکه کسی باشم که میتونه تجربه قبلی رو به زبان امروز ترجمه کنه و برای محصول، تیم و بیزنس، ارزش واقعی بسازه.
|
||||||
|
|
||||||
|
به آینده امیدوارم.
|
||||||
|
|
||||||
|
میدونم پنج سال دیگه که برگردم و این نوشته رو دوباره بخونم، هم لبخند میزنم، هم حس پختگی عمیقتری خواهم داشت.
|
||||||
|
|
||||||
|
چون میدونم این مسیر هنوز ادامه داره.
|
||||||
|
|
||||||
|
و مهمتر از همه: **الان دارم از مسیر لذت میبرم؛ چیزی که سالها دنبالش بودم همین بود: لذت بردن از مسیر، ادامه دادن و اصالت داشتن.**
|
||||||
|
|
||||||
|
## حرف آخر
|
||||||
|
|
||||||
|
این نوشته برام فقط یه روایت نیست؛ یه نقشه راه شخصیه که نشون میده از کجا شروع کردم، چه چیزهایی رو پشت سر گذاشتم و امروز چطور فکر میکنم.
|
||||||
|
|
||||||
|
اگه این مسیر رو ادامه بدم — با استمرار، اصالت و عمیق شدن — مطمئنم چند سال بعد توی جای بسیار بهتری میایستم.
|
||||||
|
|
||||||
|
و اگه تو هم جایی بین ترس «از دست دادن امنیت» و میل «ساختن مسیر خودت» گیر کردی، شاید این متن فقط یه پیام ساده برات داشته باشه:
|
||||||
|
|
||||||
|
::pull-quote{highlight}
|
||||||
|
آرومتر، عمیقتر، اما ادامه بده.
|
||||||
|
::
|
||||||
+10
-1
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"sections": {
|
"sections": {
|
||||||
"work": "Work Experience",
|
"work": "Work Experience",
|
||||||
|
"workAccordion": "7+ years building enterprise solutions and web applications.",
|
||||||
"education": "Education",
|
"education": "Education",
|
||||||
"recommendations": "Recommendations",
|
"recommendations": "Recommendations",
|
||||||
"projects": " Projects",
|
"projects": " Projects",
|
||||||
|
"projectsAccordion": "Real-world applications built for clients and the community.",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"mainTools": "Main tools",
|
"mainTools": "Main tools",
|
||||||
@@ -20,6 +22,9 @@
|
|||||||
"usedBefore": "Used before",
|
"usedBefore": "Used before",
|
||||||
"aiStack": "AI Stack"
|
"aiStack": "AI Stack"
|
||||||
},
|
},
|
||||||
|
"softSkillsAccordion": {
|
||||||
|
"label": "Mindset and interpersonal strengths that drive team success."
|
||||||
|
},
|
||||||
"softSkills": {
|
"softSkills": {
|
||||||
"problemSolving": {
|
"problemSolving": {
|
||||||
"label": "Problem solving",
|
"label": "Problem solving",
|
||||||
@@ -60,6 +65,7 @@
|
|||||||
},
|
},
|
||||||
"languageSection": {
|
"languageSection": {
|
||||||
"tagline": "Advanced English communication for global collaboration.",
|
"tagline": "Advanced English communication for global collaboration.",
|
||||||
|
"accordionLabel": "Advanced English communication for global collaboration.",
|
||||||
"duolingo": {
|
"duolingo": {
|
||||||
"label": "Duolingo English Test",
|
"label": "Duolingo English Test",
|
||||||
"value": "85"
|
"value": "85"
|
||||||
@@ -123,7 +129,10 @@
|
|||||||
"noResults": "No posts found matching your search",
|
"noResults": "No posts found matching your search",
|
||||||
"copyCode": "Copy code",
|
"copyCode": "Copy code",
|
||||||
"codeCopied": "Copied!",
|
"codeCopied": "Copied!",
|
||||||
"subscribe": "Subscribe via RSS"
|
"subscribe": "Subscribe via RSS",
|
||||||
|
"sharePost": "Share this post",
|
||||||
|
"linkCopied": "Link copied to clipboard!",
|
||||||
|
"copyFailed": "Failed to copy link"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"present": "Present",
|
"present": "Present",
|
||||||
|
|||||||
+10
-1
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"sections": {
|
"sections": {
|
||||||
"work": "سوابق کاری",
|
"work": "سوابق کاری",
|
||||||
|
"workAccordion": "بیش از ۷ سال تجربه در توسعه راهکارهای سازمانی و اپلیکیشنهای وب.",
|
||||||
"education": "تحصیلات",
|
"education": "تحصیلات",
|
||||||
"recommendations": "توصیهنامهها",
|
"recommendations": "توصیهنامهها",
|
||||||
"projects": "پروژهها",
|
"projects": "پروژهها",
|
||||||
|
"projectsAccordion": "اپلیکیشنهای واقعی ساخته شده برای مشتریان و جامعه توسعهدهندگان.",
|
||||||
"blog": "وبلاگ",
|
"blog": "وبلاگ",
|
||||||
"contact": "ارتباط",
|
"contact": "ارتباط",
|
||||||
"mainTools": "ابزارهای اصلی",
|
"mainTools": "ابزارهای اصلی",
|
||||||
@@ -20,6 +22,9 @@
|
|||||||
"usedBefore": "تجربه قبلی",
|
"usedBefore": "تجربه قبلی",
|
||||||
"aiStack": "استک هوش مصنوعی"
|
"aiStack": "استک هوش مصنوعی"
|
||||||
},
|
},
|
||||||
|
"softSkillsAccordion": {
|
||||||
|
"label": "ذهنیت و توانمندیهای بینفردی که موفقیت تیم را رقم میزنند."
|
||||||
|
},
|
||||||
"softSkills": {
|
"softSkills": {
|
||||||
"problemSolving": {
|
"problemSolving": {
|
||||||
"label": "حل مسئله",
|
"label": "حل مسئله",
|
||||||
@@ -91,7 +96,10 @@
|
|||||||
"noResults": "نتیجهای یافت نشد",
|
"noResults": "نتیجهای یافت نشد",
|
||||||
"copyCode": "کپی کد",
|
"copyCode": "کپی کد",
|
||||||
"codeCopied": "کپی شد!",
|
"codeCopied": "کپی شد!",
|
||||||
"subscribe": "اشتراک از طریق RSS"
|
"subscribe": "اشتراک از طریق RSS",
|
||||||
|
"sharePost": "اشتراکگذاری این مقاله",
|
||||||
|
"linkCopied": "لینک کپی شد!",
|
||||||
|
"copyFailed": "کپی لینک ناموفق بود"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"present": "اکنون",
|
"present": "اکنون",
|
||||||
@@ -136,6 +144,7 @@
|
|||||||
},
|
},
|
||||||
"languageSection": {
|
"languageSection": {
|
||||||
"tagline": "انگلیسی پیشرفته برای همکاری با تیمهای بینالمللی.",
|
"tagline": "انگلیسی پیشرفته برای همکاری با تیمهای بینالمللی.",
|
||||||
|
"accordionLabel": "انگلیسی پیشرفته برای همکاری با تیمهای بینالمللی.",
|
||||||
"duolingo": {
|
"duolingo": {
|
||||||
"label": "آزمون زبان دولینگو",
|
"label": "آزمون زبان دولینگو",
|
||||||
"value": "85"
|
"value": "85"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Reference in New Issue
Block a user