feat: finalize UI/UX

This commit is contained in:
mohamad
2026-08-01 16:30:24 +03:30
parent 3c50abf404
commit 28e0f7f8fe
26 changed files with 822 additions and 528 deletions
+1 -1
View File
@@ -456,5 +456,5 @@ poetry.toml
# LSP config files
pyrightconfig.json
*.md
# End of https://www.toptal.com/developers/gitignore/api/python,django,macos,pycharm
+9 -3
View File
@@ -5,6 +5,12 @@ from .models import ClientProfile
@admin.register(ClientProfile)
class ClientProfileAdmin(admin.ModelAdmin):
list_display = ("user", "company", "phone", "timezone", "updated_at")
search_fields = ("user__username", "user__email", "user__first_name", "user__last_name", "company")
list_filter = ("timezone",)
list_display = ("user", "company", "job_title", "phone", "updated_at")
search_fields = (
"user__username",
"user__email",
"user__first_name",
"user__last_name",
"company",
"job_title",
)
+9 -5
View File
@@ -11,7 +11,6 @@ class SignUpForm(UserCreationForm):
email = forms.EmailField(required=True)
company = forms.CharField(max_length=200, required=False)
phone = forms.CharField(max_length=50, required=False)
timezone = forms.ChoiceField(choices=ClientProfile.TIMEZONE_CHOICES, required=True)
class Meta:
model = User
@@ -19,7 +18,7 @@ class SignUpForm(UserCreationForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name in ("username", "first_name", "last_name", "email", "company", "phone", "timezone"):
for field_name in ("username", "first_name", "last_name", "email", "company", "phone"):
if field_name in self.fields:
self.fields[field_name].widget.attrs.setdefault("class", "form-control")
for field_name in ("password1", "password2"):
@@ -42,7 +41,6 @@ class SignUpForm(UserCreationForm):
user=user,
company=self.cleaned_data.get("company", ""),
phone=self.cleaned_data.get("phone", ""),
timezone=self.cleaned_data.get("timezone", "UTC"),
)
return user
@@ -65,11 +63,17 @@ class ClientProfileForm(forms.ModelForm):
class Meta:
model = ClientProfile
fields = ("company", "phone", "timezone")
fields = ("avatar", "company", "job_title", "phone", "bio")
widgets = {
"avatar": forms.ClearableFileInput(
attrs={"class": "profile-avatar-input", "accept": "image/jpeg,image/png,image/webp"}
),
"company": forms.TextInput(attrs={"class": "form-control"}),
"job_title": forms.TextInput(attrs={"class": "form-control"}),
"phone": forms.TextInput(attrs={"class": "form-control"}),
"timezone": forms.Select(attrs={"class": "form-control"}),
"bio": forms.Textarea(
attrs={"class": "form-control", "rows": 4, "placeholder": "A short introduction to your work and research interests"}
),
}
def __init__(self, *args, **kwargs):
@@ -0,0 +1,34 @@
# Generated by Django 5.2.15 on 2026-08-01 12:31
import apps.accounts.models
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='clientprofile',
name='timezone',
),
migrations.AddField(
model_name='clientprofile',
name='avatar',
field=models.ImageField(blank=True, upload_to=apps.accounts.models.profile_avatar_upload_to, validators=[django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp']), apps.accounts.models.validate_avatar_size]),
),
migrations.AddField(
model_name='clientprofile',
name='bio',
field=models.TextField(blank=True, max_length=600),
),
migrations.AddField(
model_name='clientprofile',
name='job_title',
field=models.CharField(blank=True, max_length=150),
),
]
+24 -18
View File
@@ -1,33 +1,39 @@
from pathlib import Path
from uuid import uuid4
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from django.db import models
class ClientProfile(models.Model):
TIMEZONE_CHOICES = [
("UTC", "UTC"),
("America/New_York", "America/New York"),
("America/Chicago", "America/Chicago"),
("America/Denver", "America/Denver"),
("America/Los_Angeles", "America/Los Angeles"),
("Europe/London", "Europe/London"),
("Europe/Berlin", "Europe/Berlin"),
("Asia/Dubai", "Asia/Dubai"),
("Asia/Tehran", "Asia/Tehran"),
("Asia/Tokyo", "Asia/Tokyo"),
("Australia/Sydney", "Australia/Sydney"),
]
def profile_avatar_upload_to(instance, filename):
suffix = Path(filename).suffix.lower()
return f"accounts/avatars/{instance.user_id}/{uuid4().hex}{suffix}"
def validate_avatar_size(image):
if image.size > 5 * 1024 * 1024:
raise ValidationError("Profile pictures must be 5 MB or smaller.")
class ClientProfile(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="client_profile",
)
company = models.CharField(max_length=200, blank=True)
job_title = models.CharField(max_length=150, blank=True)
phone = models.CharField(max_length=50, blank=True)
timezone = models.CharField(
max_length=64,
choices=TIMEZONE_CHOICES,
default="UTC",
bio = models.TextField(max_length=600, blank=True)
avatar = models.ImageField(
upload_to=profile_avatar_upload_to,
blank=True,
validators=[
FileExtensionValidator(["jpg", "jpeg", "png", "webp"]),
validate_avatar_size,
],
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+50 -3
View File
@@ -16,7 +16,6 @@ class SignUpFormTest(TestCase):
"email": "ada@example.com",
"company": "Analytical Engines",
"phone": "+1 555 0100",
"timezone": "UTC",
"password1": "Str0ngPass!word",
"password2": "Str0ngPass!word",
}
@@ -26,7 +25,6 @@ class SignUpFormTest(TestCase):
self.assertEqual(user.email, "ada@example.com")
profile = ClientProfile.objects.get(user=user)
self.assertEqual(profile.company, "Analytical Engines")
self.assertEqual(profile.timezone, "UTC")
class AccountViewsTest(TestCase):
@@ -34,6 +32,15 @@ class AccountViewsTest(TestCase):
response = self.client.get(reverse("accounts:signup"))
self.assertEqual(response.status_code, 200)
def test_logged_out_nav_exposes_login_and_registration(self):
response = self.client.get(reverse("pages:home"))
navbar = response.content.decode().split("</nav>", 1)[0]
self.assertIn("Login", navbar)
self.assertIn("Start a Project", navbar)
self.assertIn(reverse("accounts:login"), navbar)
self.assertIn(reverse("accounts:signup"), navbar)
def test_signup_creates_account_and_logs_in(self):
response = self.client.post(
reverse("accounts:signup"),
@@ -44,7 +51,6 @@ class AccountViewsTest(TestCase):
"email": "grace@example.com",
"company": "",
"phone": "",
"timezone": "UTC",
"password1": "Str0ngPass!word",
"password2": "Str0ngPass!word",
},
@@ -58,6 +64,47 @@ class AccountViewsTest(TestCase):
self.assertEqual(response.status_code, 302)
self.assertIn(reverse("accounts:login"), response.url)
def test_authenticated_nav_uses_sidebar_for_account_actions(self):
user = User.objects.create_user(
username="workspace-user",
email="workspace@example.com",
password="Str0ngPass!word",
)
self.client.force_login(user)
response = self.client.get(reverse("projects:dashboard"))
self.assertContains(response, "Profile &amp; account")
self.assertContains(response, "Project briefs")
navbar = response.content.decode().split("</nav>", 1)[0]
self.assertNotIn("My Projects", navbar)
self.assertNotIn("Logout", navbar)
self.assertIn("navbar-account", navbar)
self.assertIn("navbar-account-dropdown", navbar)
self.assertIn("Your profile", navbar)
self.assertIn("New project brief", navbar)
self.assertIn("Contact support", navbar)
self.assertIn("Sign out", navbar)
self.assertIn("default-avatar.png", navbar)
def test_profile_exposes_professional_fields_and_avatar_upload(self):
user = User.objects.create_user(
username="researcher",
first_name="Marie",
last_name="Curie",
email="marie@example.com",
password="Str0ngPass!word",
)
self.client.force_login(user)
response = self.client.get(reverse("accounts:profile"))
self.assertContains(response, 'enctype="multipart/form-data"')
self.assertContains(response, 'name="avatar"')
self.assertContains(response, 'name="job_title"')
self.assertContains(response, 'name="bio"')
self.assertNotContains(response, 'name="timezone"')
def test_logout_requires_post_and_clears_session(self):
self.client.login(username="newclient", password="Str0ngPass!word")
response = self.client.get(reverse("accounts:logout"))
+2 -2
View File
@@ -51,7 +51,7 @@ class HomeViewTest(TestCase):
from apps.projects.models import ProjectBrief
user = User.objects.create_user(username="client", email="c@example.com", password="x")
ClientProfile.objects.create(user=user, timezone="UTC")
ClientProfile.objects.create(user=user)
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_PROJECTS,
title="Showcase",
@@ -86,7 +86,7 @@ class HomeViewTest(TestCase):
from apps.projects.models import ProjectBrief
user = User.objects.create_user(username="c2", email="c2@example.com", password="x")
ClientProfile.objects.create(user=user, timezone="UTC")
ClientProfile.objects.create(user=user)
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_PROJECTS,
title="Showcase",
@@ -19,7 +19,6 @@ CLIENTS = [
"password": "ClientDemo2026!",
"company": "Northside Imaging Lab",
"phone": "+1 604 555 0101",
"timezone": "America/Vancouver",
},
{
"username": "sarah.research",
@@ -29,7 +28,6 @@ CLIENTS = [
"password": "ClientDemo2026!",
"company": "Pacific Oncology Research",
"phone": "+1 604 555 0102",
"timezone": "America/Los_Angeles",
},
{
"username": "devteam",
@@ -39,7 +37,6 @@ CLIENTS = [
"password": "ClientDemo2026!",
"company": "BioFlow Analytics",
"phone": "+44 20 7946 0103",
"timezone": "Europe/London",
},
]
@@ -192,7 +189,6 @@ class Command(BaseCommand):
defaults={
"company": client["company"],
"phone": client["phone"],
"timezone": client["timezone"],
},
)
action = "Created" if created else "Updated"
+2 -2
View File
@@ -16,13 +16,13 @@ class ProjectPortalTest(TestCase):
first_name="Client",
last_name="User",
)
ClientProfile.objects.create(user=self.user, timezone="UTC")
ClientProfile.objects.create(user=self.user)
self.other = User.objects.create_user(
username="other",
email="other@example.com",
password="Str0ngPass!word",
)
ClientProfile.objects.create(user=self.other, timezone="UTC")
ClientProfile.objects.create(user=self.other)
def _brief_payload(self):
return {
-256
View File
@@ -1,256 +0,0 @@
# Managed Projects Roadmap — Simple → Advanced
Expand the existing Tecvico Django site so **clients define projects; Tecvico delivers them**. No freelancer marketplace — users submit briefs, your team quotes, builds, and ships.
---
## Is This Doable on the Current Stack?
**Yes — and simpler than a marketplace.** No two-sided matching, escrow to third parties, or dispute flows between strangers. You are the sole provider.
| Already in place | Reuse for managed projects |
| -------------------------------- | ------------------------------------------- |
| Django + PostgreSQL + Docker | Core backend |
| `apps.pages` + contact form | Evolve into project brief submission |
| File uploads (`contact_uploads`) | Brief attachments, deliverables |
| `apps.products` CMS patterns | Service catalog / package pages |
| Admin panel | Intake queue, quotes, assignment, delivery |
| Email + templates | Status updates, quote sent, milestone ready |
**New apps to add:**
- `apps.accounts` — client login, org profiles (Plan 2+)
- `apps.projects` — briefs, quotes, orders, milestones, deliverables
- `apps.messaging` — client ↔ Tecvico threads per project
- `apps.payments` — client invoicing via Stripe (Plan 2+)
**Internal-only (admin):** team assignment, internal notes, time tracking — no public freelancer profiles.
**Verdict:** Plan 1 is close to an enhanced contact flow. Plan 23 add quoting, payments, and a client portal — all standard Django.
---
## Plan 1 — Simple (MVP) · 36 weeks
**Goal:** Clients submit project briefs online; Tecvico reviews and delivers off-platform billing if needed.
| Milestone | Weeks | Deliverable |
| ------------------ | ----- | ----------------------------------------------- |
| M1 Client accounts | 1 | Sign up, login, password reset |
| M2 Project briefs | 12 | Structured submission form + attachments |
| M3 Status tracking | 23 | Client sees request status; email notifications |
| M4 Admin intake | 34 | Review queue, status updates, internal notes |
### Feature detail
**Accounts**
- Email/password signup (client role only)
- Profile: name, company, phone, timezone
- Dashboard: list of my project requests
**Project brief**
- Fields: title, category (web, data, research, etc.), description, goals, budget range, desired deadline, reference links
- Attachments: specs, mockups, datasets (reuse upload patterns)
- Status: `submitted``under_review``quoted``accepted` / `declined``in_progress``delivered``closed`
- Client can edit brief while status is `submitted` only
**Notifications**
- Email to client on status change
- Email to admin on new submission
**Admin**
- Intake list with filters (status, category, date)
- View brief + files; add internal notes (not visible to client)
- Manually set status and paste quote text (PDF/email off-platform OK)
**Out of scope:** online quotes, payments, messaging, milestones.
---
## Plan 2 — Medium (Client Portal) · 12 months
**Goal:** Quote, pay, and deliver on-platform. Productized packages + custom projects.
| Milestone | Weeks | Deliverable |
| ------------- | ----- | ----------------------------------------------------- |
| M1 Foundation | 12 | Plan 1 + email verify, service catalog pages |
| M2 Quotes | 34 | Admin builds quote; client accepts/declines in portal |
| M3 Payments | 56 | Stripe deposit + milestone invoices |
| M4 Delivery | 78 | Milestones, deliverable uploads, revision rounds |
| M5 Comms | 910 | Per-project messaging, client dashboard polish |
### Feature detail
**Service catalog**
- CMS pages for fixed offerings (e.g. “Landing page”, “Radiomics pipeline audit”)
- Package tiers: scope summary, starting price, typical timeline
- “Start from template” pre-fills brief fields
**Quotes**
- Admin creates quote from brief: line items, total, timeline, revision count included
- Client receives email + in-portal quote view
- Accept → order created; decline → brief archived with reason
**Orders & milestones**
- Order states: `awaiting_deposit``active``milestone_review``revision``completed`
- Milestones: name, due date, amount, description of deliverable
- Tecvico uploads files per milestone; client approves or requests revision (capped rounds)
- Change requests: client submits add-on brief → admin sends revised quote
**Payments (Stripe)**
- Deposit on accept (e.g. 3050%); remainder split across milestones
- Checkout Session per invoice; receipt PDF
- Refund policy flags (admin-only)
**Messaging**
- Thread per project: client ↔ assigned PM
- File attach in thread; email digest on new message
**Admin**
- Assign internal owner (staff user) per project
- Pipeline board: new → quoted → active → blocked → done
- Basic reports: open projects, revenue this month
**Out of scope:** e-sign contracts, org/team accounts, automated estimates, real-time chat.
---
## Plan 3 — Advanced (Full Client Platform) · 23 months
**Goal:** Scalable delivery ops — enterprise clients, SLAs, automation, integrations.
| Milestone | Months | Deliverable |
| --------------- | ------ | --------------------------------------------- |
| M1 Hardening | 1 | Plan 2 + 2FA, OAuth, org accounts |
| M2 Sales flow | 12 | E-sign, ballpark estimator, kickoff booking |
| M3 Delivery ops | 23 | SLA timers, internal time log, task checklist |
| M4 Enterprise | 34 | Multi-user orgs, API, webhooks |
| M5 Growth | 45 | Referrals, analytics, PWA, knowledge base |
### Feature detail
**Auth & organizations**
- Google / LinkedIn OAuth; optional 2FA
- Organization account: owner + members, shared project list, role (viewer / submitter / billing)
- Billing contact separate from project contact
**Pre-sale**
- Ballpark estimator: category + scope sliders → indicative range (admin-configured rules)
- Discovery call booking (Calendly embed or built-in slot picker)
- Digital SOW + NDA (DocuSign hook or in-app accept with audit trail)
**Delivery workspace**
- Shared project hub: brief, quote, contract, timeline, files, messages
- Visual timeline / milestone Gantt for client
- SLA badges: response within X hours, milestone due alerts (Celery + email)
- Internal: task checklist, time entries per staff, capacity view (admin)
**Revisions & scope**
- Structured revision requests (what to change, priority)
- Scope creep flow: auto-flag when revision count exceeded → change order quote
**Payments+**
- Multi-currency quotes; recurring maintenance plans (Stripe Subscription)
- Pro-forma + final invoice; export for accounting
**Enterprise & integrations**
- REST API: submit brief, poll status, download deliverables
- Webhooks: `quote.sent`, `milestone.delivered`, `project.completed`
- SSO option (SAML) for large clients — optional phase
**Knowledge & retention**
- Client-facing FAQ / “how we work” tied to project phase
- Post-delivery satisfaction survey + testimonial request
- Referral codes (credit on next project)
**Admin analytics**
- Funnel: brief → quote → accept → complete
- Utilization, avg delivery time, revenue by service line
**Out of scope:** marketplace, freelancer payouts, native mobile apps.
---
## Comparison
| | Simple | Medium | Advanced |
| ------------------------------- | ---------- | -------- | ----------------- |
| Project brief submission | ✓ | ✓ | ✓ + templates |
| Online quotes | — | ✓ | ✓ + estimator |
| Stripe payments | — | ✓ | ✓ + subscriptions |
| Milestones & deliverables | — | ✓ | ✓ + SLA |
| Client ↔ Tecvico messaging | — | ✓ | ✓ + digests |
| Service catalog / packages | — | ✓ | ✓ |
| Org / team accounts | — | — | ✓ |
| E-sign / contracts | — | — | ✓ |
| API / webhooks | — | — | ✓ |
| Internal ops (assign, time log) | notes only | pipeline | full |
---
## Managed vs Marketplace
| | Managed (this doc) | Marketplace (`marketplace-roadmap.md`) |
| ------------ | -------------------------- | -------------------------------------- |
| Who delivers | Tecvico team | Freelancers / clients hire each other |
| Payments | Client → Tecvico | Escrow, payouts, commissions |
| Complexity | Lower | Higher (trust, disputes, matching) |
| Best when | You sell your own services | You broker others' work |
---
## Recommended Path
1. **Plan 1** — ship brief + status portal; keep quoting via email if needed.
2. **Plan 2** — add quotes and Stripe once you have repeatable delivery workflow.
3. **Plan 3** — when project volume needs SLAs, org accounts, and ops dashboards.
*Timelines assume extending the current codebase; bespoke UI redesign adds ~20%.*
+385 -45
View File
@@ -33,6 +33,19 @@
--text-muted: #9ca3af;
--text-link: #3051ff;
/* Semantic UI colors */
--color-success: #087a53;
--color-success-soft: #e9f8f2;
--color-warning: #9a6b0b;
--color-warning-soft: #fff6df;
--color-danger: #b42318;
--color-danger-soft: #fff0ee;
--color-info: #3051ff;
--color-info-soft: #eef1ff;
--border-default: #e3e7ee;
--border-strong: #cbd2dd;
--focus-ring: 0 0 0 3px rgba(48, 81, 255, 0.16);
--footer-bg: #1f2937;
--footer-bottom-bg: #000000;
@@ -42,6 +55,10 @@
--radius-xl: 20px;
--radius-pill: 999px;
--shadow-xs: 0 1px 2px rgba(17, 24, 39, 0.05);
--shadow-sm: 0 2px 8px rgba(17, 24, 39, 0.07);
--shadow-md: 0 10px 28px rgba(17, 24, 39, 0.10);
--spacing-xs: 0.5rem;
--spacing-sm: 1rem;
--spacing-md: 1.5rem;
@@ -60,8 +77,8 @@
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--transition-bounce: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
--font-sans: 'Ubuntu', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-heading: 'Open Sans', 'Ubuntu', sans-serif;
--font-sans: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-heading: 'Ubuntu', 'Open Sans', sans-serif;
}
/* ============================================================
@@ -104,6 +121,11 @@ a:hover { color: var(--accent-blue-light); }
ul, ol { list-style: none; }
address { font-style: normal; }
:where(a, button, input, select, textarea):focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
@@ -181,18 +203,25 @@ h1, h2, h3, h4, h5, h6 {
Glass Card Component
============================================================ */
.glass-card {
background: #ffffff;
border: none;
background: var(--bg-surface);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-sm);
transition: var(--transition-smooth);
overflow: hidden;
}
.glass-card:hover {
background: #ffffff;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
transform: scale(1.02);
background: var(--bg-surface);
border-color: var(--border-default);
box-shadow: var(--shadow-sm);
}
a.glass-card:hover,
.section-item-link:hover,
.product-overview-card:has(.btn-primary):hover {
border-color: rgba(48, 81, 255, 0.24);
box-shadow: var(--shadow-md);
}
/* ============================================================
@@ -203,14 +232,16 @@ h1, h2, h3, h4, h5, h6 {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.6rem;
border-radius: var(--radius-pill);
font-size: 0.9rem;
font-weight: 600;
justify-content: center;
min-height: 44px;
padding: 0.65rem 1.25rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 700;
font-family: var(--font-sans);
cursor: pointer;
border: none;
transition: var(--transition-smooth);
border: 1px solid transparent;
transition: color .18s ease, background-color .18s ease, border-color .18s ease, box-shadow .18s ease, transform .18s ease;
white-space: nowrap;
text-decoration: none;
}
@@ -218,37 +249,37 @@ h1, h2, h3, h4, h5, h6 {
.btn-primary {
background: var(--accent-blue);
color: #fff;
border: 2px solid transparent;
border-radius: var(--radius-sm);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
box-shadow: 0 5px 12px rgba(48, 81, 255, 0.18);
}
.btn-primary:hover {
color: var(--accent-blue);
background: transparent;
border-color: var(--accent-blue);
box-shadow: none;
transform: none;
color: #fff;
background: var(--accent-blue-dark);
border-color: var(--accent-blue-dark);
box-shadow: 0 7px 16px rgba(48, 81, 255, 0.22);
transform: translateY(-1px);
}
.btn-ghost {
background: #fff;
color: var(--accent-blue-dark);
border: 2px solid transparent;
border-radius: var(--radius-sm);
color: var(--accent-blue);
border-color: var(--border-strong);
box-shadow: var(--shadow-xs);
}
.btn-ghost:hover {
background: var(--accent-blue);
background: var(--color-info-soft);
border-color: var(--accent-blue);
color: #fff;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
transform: none;
color: var(--accent-blue-dark);
box-shadow: var(--shadow-xs);
transform: translateY(-1px);
}
.btn-sm {
padding: 0.5rem 1.1rem;
font-size: 0.82rem;
.btn-sm,
.btn--sm {
min-height: 36px;
padding: 0.45rem 0.9rem !important;
font-size: 0.78rem !important;
}
/* ============================================================
@@ -394,6 +425,108 @@ h1, h2, h3, h4, h5, h6 {
padding: 0.5rem 1.25rem;
}
.nav-link--cta:hover,
.nav-link--cta:focus-visible,
.nav-link--cta.active {
color: #fff !important;
background: var(--accent-blue);
border-color: var(--accent-blue);
box-shadow: none;
}
.navbar-account {
position: relative;
display: grid;
place-items: center;
width: 42px;
height: 42px;
margin-left: .35rem;
border: 1px solid var(--border-default);
border-radius: 50%;
background: #fff;
cursor: pointer;
list-style: none;
}
.navbar-account::-webkit-details-marker { display: none; }
.navbar-account-menu { position: relative; }
.navbar-account-menu > summary::marker { content: ''; }
.navbar-account:hover {
border-color: var(--accent-blue);
box-shadow: var(--focus-ring);
}
.navbar-account__avatar {
display: grid;
place-items: center;
width: 34px;
height: 34px;
overflow: hidden;
border-radius: 50%;
background: linear-gradient(145deg, var(--accent-blue), #1736b8);
color: #fff;
font-size: .75rem;
font-weight: 700;
}
.navbar-account__avatar img { width: 100%; height: 100%; object-fit: cover; }
.navbar-account__status { position: absolute; right: 1px; bottom: 1px; width: 10px; height: 10px; border: 2px solid #fff; border-radius: 50%; background: var(--color-success); }
.navbar-account-dropdown {
position: absolute;
top: calc(100% + 12px);
right: 0;
width: min(320px, calc(100vw - 2rem));
padding: .65rem;
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: #fff;
box-shadow: 0 18px 48px rgba(17, 24, 39, .16);
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
pointer-events: none;
transition: opacity .16s ease, visibility .16s ease, transform .16s ease;
z-index: 1100;
}
.navbar-account-dropdown::before { content: ''; position: absolute; left: 0; right: 0; top: -12px; height: 12px; }
.navbar-account-menu[open] .navbar-account-dropdown,
.navbar-account-menu:focus-within .navbar-account-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
@media (min-width: 769px) {
.navbar-account-menu:hover .navbar-account-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
}
.navbar-account-dropdown__identity { display: grid; grid-template-columns: 44px minmax(0,1fr); gap: .75rem; align-items: center; padding: .65rem .7rem .85rem; }
.navbar-account-dropdown__identity img { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; }
.navbar-account-dropdown__identity div { min-width: 0; display: grid; }
.navbar-account-dropdown__identity strong { overflow: hidden; color: var(--text-primary); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.navbar-account-dropdown__identity span { overflow: hidden; color: var(--text-muted); font-size: .67rem; text-overflow: ellipsis; white-space: nowrap; }
.navbar-account-dropdown__section { display: grid; gap: .15rem; padding: .5rem 0; border-top: 1px solid var(--border-default); }
.navbar-account-dropdown__section a { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: .55rem; align-items: center; min-height: 48px; padding: .5rem .7rem; border-radius: var(--radius-sm); color: #445066; }
.navbar-account-dropdown__section a:hover { background: var(--color-info-soft); color: var(--accent-blue); }
.navbar-account-dropdown__section a > span:first-child { display: grid; place-items: center; width: 28px; font: 700 1rem Arial,sans-serif; }
.navbar-account-dropdown__section a > span:last-child { min-width: 0; display: grid; }
.navbar-account-dropdown__section a strong { color: inherit; font-size: .75rem; }
.navbar-account-dropdown__section a small { margin-top: .08rem; color: var(--text-muted); font-size: .62rem; }
.navbar-account-dropdown__section--compact a { display: flex; min-height: 36px; padding: .45rem .7rem; font-size: .72rem; font-weight: 600; }
.navbar-account-dropdown__section--compact form { margin: 0; }
.navbar-account-dropdown__section--compact button { width: 100%; min-height: 36px; padding: .45rem .7rem; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-danger); font: 600 .72rem var(--font-sans); text-align: left; cursor: pointer; }
.navbar-account-dropdown__section--compact button:hover { background: var(--color-danger-soft); }
.nav-logout-form {
margin: 0;
}
@@ -2880,7 +3013,8 @@ a.citation-count-badge:hover {
width: 100%;
padding: 0.72rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid #d1d5db;
min-height: 44px;
border: 1px solid var(--border-strong);
background: #ffffff;
color: var(--text-primary);
font-family: inherit;
@@ -2892,9 +3026,10 @@ a.citation-count-badge:hover {
}
.form-group input:focus,
.form-group textarea:focus {
.form-group textarea:focus,
.form-group select:focus {
border-color: var(--accent-blue);
box-shadow: 0 0 0 3px rgba(48, 81, 255, 0.12);
box-shadow: var(--focus-ring);
background: #ffffff;
}
@@ -2905,15 +3040,37 @@ a.citation-count-badge:hover {
.form-group.has-error input,
.form-group.has-error textarea,
.form-group.has-error select,
.form-group:has(.errorlist) input,
.form-group:has(.errorlist) textarea,
.form-group:has(.errorlist) select,
.form-group.has-error input[type="file"] {
border-color: rgba(239, 68, 68, 0.5);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.form-group input::placeholder,
.form-group textarea::placeholder { color: #9099a8; opacity: 1; }
.form-group input:disabled,
.form-group textarea:disabled,
.form-group select:disabled {
cursor: not-allowed;
background: #f2f4f7;
color: #7d8796;
}
.errorlist {
margin-top: .35rem;
color: var(--color-danger);
font-size: .76rem;
font-weight: 600;
}
.form-field-error {
display: block;
font-size: 0.78rem;
color: rgb(252, 165, 165);
color: var(--color-danger);
margin-top: 0.3rem;
min-height: 1em;
}
@@ -2947,11 +3104,6 @@ a.citation-count-badge:hover {
to { transform: rotate(360deg); }
}
.btn--sm {
padding: 0.45rem 1.1rem !important;
font-size: 0.85rem !important;
}
.contact-sidebar {
display: flex;
flex-direction: column;
@@ -3292,6 +3444,11 @@ a.citation-count-badge:hover {
font-size: 0.95rem;
}
.nav-item--account { padding-top: .5rem; border-top: 1px solid var(--border-default); }
.navbar-account { margin: 0; }
.navbar-account-dropdown { position: static; width: 100%; margin-top: .5rem; box-shadow: none; transform: none; }
.navbar-account-menu:not([open]) .navbar-account-dropdown { display: none; }
.nav-item.has-megamenu:hover .nav-arrow {
transform: none;
}
@@ -3951,14 +4108,14 @@ a.citation-count-badge:hover {
.flash-message--success,
.flash-message--info {
background: rgba(16, 185, 129, 0.12);
color: #047857;
background: var(--color-success-soft);
color: var(--color-success);
border: 1px solid rgba(16, 185, 129, 0.25);
}
.flash-message--error {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
background: var(--color-danger-soft);
color: var(--color-danger);
border: 1px solid rgba(239, 68, 68, 0.25);
}
@@ -4077,6 +4234,179 @@ a.citation-count-badge:hover {
padding-left: 1.2rem;
}
/* ============================================================
Authenticated client workspace
============================================================ */
.portal-body {
--dashboard-ink: #172033;
--dashboard-muted: #647087;
--dashboard-line: #e5e9f1;
--dashboard-canvas: #f5f7fb;
background: var(--dashboard-canvas);
}
.portal-body #main-content { min-height: calc(100vh - var(--navbar-height)); }
.dashboard-shell {
display: grid;
grid-template-columns: 264px minmax(0, 1fr);
min-height: calc(100vh - var(--navbar-height));
max-width: 1600px;
margin: 0 auto;
background: var(--dashboard-canvas);
}
.dashboard-sidebar {
position: sticky;
top: var(--navbar-height);
height: calc(100vh - var(--navbar-height));
display: flex;
flex-direction: column;
padding: 1.75rem 1.15rem 1.25rem;
background: #fff;
border-right: 1px solid var(--dashboard-line);
z-index: 5;
}
.dashboard-sidebar__profile { display: flex; align-items: center; gap: .8rem; padding: 0 .55rem 1.6rem; }
.dashboard-avatar,
.profile-context-card__avatar {
display: grid; place-items: center; flex: 0 0 auto;
width: 42px; height: 42px; border-radius: 12px;
color: #fff; background: linear-gradient(145deg, var(--accent-blue), #1736b8);
font-weight: 700; box-shadow: 0 7px 18px rgba(48,81,255,.2);
}
.dashboard-avatar img,
.profile-context-card__avatar img,
.profile-avatar-preview img { width: 100%; height: 100%; object-fit: cover; }
.dashboard-avatar img { border-radius: inherit; }
.dashboard-sidebar__identity { min-width: 0; display: grid; }
.dashboard-sidebar__identity strong { color: var(--dashboard-ink); font-size: .88rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dashboard-sidebar__identity span { color: var(--dashboard-muted); font-size: .7rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dashboard-nav { display: grid; gap: .28rem; }
.dashboard-nav__label { margin: .3rem .75rem .55rem; color: #9aa3b3; font-size: .65rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.dashboard-nav__label--secondary { margin-top: 1.35rem; }
.dashboard-nav__link { display: flex; align-items: center; gap: .78rem; min-height: 44px; padding: .65rem .75rem; border-radius: 9px; color: #505d73; font-size: .86rem; font-weight: 500; }
.dashboard-nav__link:hover { color: var(--accent-blue); background: #f4f6ff; }
.dashboard-nav__link.is-active { color: var(--accent-blue); background: #eef1ff; font-weight: 700; box-shadow: inset 3px 0 0 var(--accent-blue); }
.dashboard-nav__icon { display: grid; place-items: center; width: 24px; height: 24px; color: currentColor; font-family: Arial, sans-serif; font-size: 1.05rem; font-weight: 700; }
.dashboard-sidebar__footer { margin-top: auto; padding: 1rem .5rem 0; border-top: 1px solid var(--dashboard-line); }
.dashboard-security-note { display: flex; gap: .6rem; margin-bottom: .8rem; color: var(--dashboard-muted); font-size: .68rem; line-height: 1.45; }
.dashboard-security-note strong { display: block; color: #3d485b; font-size: .72rem; }
.dashboard-security-note__mark { width: 8px; height: 8px; margin-top: 5px; flex: 0 0 auto; border-radius: 50%; background: #22a06b; box-shadow: 0 0 0 4px rgba(34,160,107,.1); }
.dashboard-signout { width: 100%; padding: .6rem; border: 0; background: transparent; color: #7b8494; font: 600 .76rem var(--font-sans); text-align: left; cursor: pointer; }
.dashboard-signout:hover { color: #b42318; }
.dashboard-main { min-width: 0; padding: 2.4rem clamp(1.25rem, 3.5vw, 3.5rem) 4rem; }
.dashboard-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 2rem; max-width: 1180px; margin: 0 auto 2rem; }
.dashboard-header > div:first-child { max-width: 720px; }
.dashboard-eyebrow { margin-bottom: .45rem; color: var(--accent-gold) !important; font-size: .7rem !important; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.dashboard-header h1 { color: var(--dashboard-ink); font: 700 clamp(1.75rem, 3vw, 2.35rem)/1.15 var(--font-heading); letter-spacing: -.035em; }
.dashboard-header p { margin-top: .55rem; color: var(--dashboard-muted); font-size: .92rem; }
.dashboard-header__meta { margin-left: .45rem; }
.dashboard-header__actions { display: flex; gap: .65rem; flex-wrap: wrap; }
.dashboard-content { max-width: 1180px; margin: 0 auto; }
.dashboard-content .btn-primary, .dashboard-header .btn-primary { box-shadow: 0 7px 16px rgba(48,81,255,.17); }
.dashboard-metrics { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 1rem; margin-bottom: 1.25rem; }
.metric-card { display: grid; min-height: 154px; padding: 1.2rem 1.25rem; background: #fff; border: 1px solid var(--dashboard-line); border-radius: 13px; box-shadow: 0 2px 8px rgba(22,32,51,.035); }
.metric-card__top { display: flex; align-items: center; justify-content: space-between; margin-bottom: .85rem; }
.metric-card__icon { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: #eef1ff; color: var(--accent-blue); font: 700 .75rem var(--font-heading); }
.metric-card__icon--gold { color: #9a6b0b; background: #fff6df; }
.metric-card__icon--green { color: #087a53; background: #e9f8f2; }
.metric-card__trend { color: #8b94a5; font-size: .66rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
.metric-card > strong { color: var(--dashboard-ink); font: 700 1.55rem/1.15 var(--font-heading); letter-spacing: -.025em; }
.metric-card > span { align-self: end; color: var(--dashboard-muted); font-size: .76rem; }
.dashboard-panel { background: #fff; border: 1px solid var(--dashboard-line); border-radius: 13px; box-shadow: 0 2px 9px rgba(22,32,51,.035); overflow: hidden; }
.dashboard-panel__header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 1.25rem 1.4rem; border-bottom: 1px solid var(--dashboard-line); }
.dashboard-panel__header h2 { font-size: 1rem; letter-spacing: -.015em; }
.dashboard-panel__header p { margin-top: .25rem; color: var(--dashboard-muted); font-size: .76rem; }
.dashboard-panel__count { padding: .25rem .6rem; border-radius: 99px; background: #f1f3f7; color: #687386; font-size: .68rem; font-weight: 700; }
.portal-table th, .portal-table td { border-color: var(--dashboard-line); }
.portal-table th { padding: .78rem 1.15rem; background: #fafbfc; color: #7e8797; font-size: .65rem; letter-spacing: .07em; }
.portal-table td { padding: 1rem 1.15rem; color: #4c586c; font-size: .78rem; }
.portal-table tbody tr { transition: background .16s ease; }
.portal-table tbody tr:hover { background: #fafbff; }
.portal-table tbody tr:last-child td { border-bottom: 0; }
.project-title-link { color: var(--dashboard-ink); font-weight: 700; }
.project-title-link:hover, .table-action:hover { color: var(--accent-blue); }
.table-muted { color: #9ba3b1; }
.table-action { color: var(--accent-blue); font-size: .74rem; font-weight: 700; white-space: nowrap; }
.status-pill { position: relative; gap: .35rem; padding: .28rem .58rem; font-size: .65rem; }
.status-pill::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.portal-empty { display: grid; justify-items: center; padding: 3.5rem 1.5rem; }
.portal-empty__symbol { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 1rem; border: 1px dashed #9daceb; border-radius: 14px; background: #f4f6ff; color: var(--accent-blue); font: 300 1.65rem Arial,sans-serif; }
.portal-empty h3 { font-size: 1.05rem; }
.portal-empty p { max-width: 500px; margin: .55rem auto 1.25rem; color: var(--dashboard-muted); font-size: .82rem; }
.dashboard-form-layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 1.25rem; align-items: start; }
.dashboard-form-layout--wizard { grid-template-columns: minmax(0, 1fr) 300px; }
.dashboard-form-card { padding: 0 1.4rem 1.5rem; }
.dashboard-form-card .dashboard-panel__header { margin: 0 -1.4rem 1.4rem; }
.dashboard-form-card .form-group { margin-bottom: 1.15rem; }
.dashboard-form-card .form-group label { color: #344054; font-size: .76rem; font-weight: 700; }
.dashboard-form-card .form-group input:not([type=file]), .dashboard-form-card .form-group textarea, .dashboard-form-card .form-group select { min-height: 44px; border: 1px solid #d7dce5; border-radius: 8px; background: #fff; color: var(--dashboard-ink); font-size: .82rem; box-shadow: 0 1px 2px rgba(16,24,40,.03); }
.dashboard-form-card .form-group textarea { min-height: 130px; }
.dashboard-form-card .form-group input:focus, .dashboard-form-card .form-group textarea:focus, .dashboard-form-card .form-group select:focus { border-color: #7790ff; outline: 3px solid rgba(48,81,255,.09); box-shadow: none; }
.dashboard-form-card .form-hint { color: #858fa0; font-size: .69rem; }
.dashboard-form-actions { display: flex; justify-content: flex-end; padding-top: 1.1rem; border-top: 1px solid var(--dashboard-line); }
.profile-context-card, .brief-guidance-card { padding: 1.5rem; border-radius: 13px; background: #172449; color: #fff; box-shadow: 0 12px 30px rgba(23,36,73,.14); }
.profile-context-card__avatar { width: 54px; height: 54px; margin-bottom: 1rem; background: rgba(255,255,255,.14); box-shadow: none; }
.profile-context-card__avatar { overflow: hidden; }
.profile-context-card h2, .brief-guidance-card h2 { color: #fff; font-size: 1rem; }
.profile-context-card > p { margin-top: .3rem; color: rgba(255,255,255,.65); font-size: .72rem; overflow-wrap: anywhere; }
.profile-context-card__rule { height: 1px; margin: 1.35rem 0; background: rgba(255,255,255,.13); }
.profile-context-card__label { display: block; margin-bottom: .5rem; color: rgba(255,255,255,.55); font-size: .62rem; letter-spacing: .08em; text-transform: uppercase; }
.profile-context-card__expertise { margin-top: 1rem; padding: .65rem .75rem; border: 1px solid rgba(255,255,255,.12); border-radius: var(--radius-sm); color: rgba(255,255,255,.72); font-size: .68rem; line-height: 1.5; }
.profile-status { display: flex; align-items: center; gap: .45rem; font-size: .72rem; }
.profile-status span { width: 7px; height: 7px; border-radius: 50%; background: #47d7a0; box-shadow: 0 0 0 4px rgba(71,215,160,.12); }
.brief-guidance-card__eyebrow { color: #e4ba65; font-size: .64rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.brief-guidance-card h2 { margin-top: .55rem; line-height: 1.4; }
.brief-guidance-card ul { display: grid; gap: 1.1rem; margin-top: 1.4rem; }
.brief-guidance-card li { display: flex; gap: .7rem; }
.brief-guidance-card li > span { display: grid; place-items: center; width: 24px; height: 24px; flex: 0 0 auto; border-radius: 7px; background: rgba(255,255,255,.1); color: #e4ba65; font-size: .67rem; font-weight: 700; }
.brief-guidance-card li strong { color: #fff; font-size: .72rem; }
.brief-guidance-card li p { margin-top: .15rem; color: rgba(255,255,255,.6); font-size: .67rem; line-height: 1.5; }
.brief-guidance-card__note { margin-top: 1.4rem; padding-top: 1rem; border-top: 1px solid rgba(255,255,255,.12); color: rgba(255,255,255,.6); font-size: .67rem; }
.portal-detail-grid .portal-card { padding: 1.5rem; }
.profile-avatar-field { display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 1.1rem; align-items: center; padding: 1rem; border: 1px solid var(--dashboard-line); border-radius: var(--radius-md); background: #fafbfc; }
.profile-avatar-preview { display: grid; place-items: center; width: 82px; height: 82px; overflow: hidden; border-radius: 18px; background: linear-gradient(145deg, var(--accent-blue), #1736b8); color: #fff; font: 700 1.35rem var(--font-heading); }
.profile-avatar-field input[type="file"] { width: 100%; padding: .45rem; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: #fff; font-size: .75rem; }
.profile-avatar-field input[type="checkbox"] { width: auto; min-height: auto; margin: .65rem .25rem 0 .75rem; }
.profile-form-section { padding-top: 1.25rem; margin-top: 1.25rem; border-top: 1px solid var(--dashboard-line); }
.profile-form-section h3 { margin-bottom: 1rem; color: #465166; font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; }
@media (max-width: 980px) {
.dashboard-shell { grid-template-columns: 78px minmax(0,1fr); }
.dashboard-sidebar { padding-inline: .7rem; }
.dashboard-sidebar__profile { justify-content: center; padding-inline: 0; }
.dashboard-sidebar__identity, .dashboard-nav__label, .dashboard-nav__link span:last-child, .dashboard-sidebar__footer { display: none; }
.dashboard-nav__link { justify-content: center; padding-inline: 0; }
.dashboard-form-layout, .dashboard-form-layout--wizard { grid-template-columns: 1fr; }
.profile-context-card, .brief-guidance-card { order: -1; }
}
@media (max-width: 700px) {
.dashboard-shell { display: block; }
.dashboard-sidebar { position: static; width: 100%; height: auto; padding: .65rem 1rem; border-right: 0; border-bottom: 1px solid var(--dashboard-line); }
.dashboard-sidebar__profile, .dashboard-nav__label, .dashboard-sidebar__footer { display: none; }
.dashboard-nav { display: flex; overflow-x: auto; }
.dashboard-nav__link { flex: 0 0 auto; min-height: 38px; padding: .45rem .7rem; white-space: nowrap; }
.dashboard-nav__link span:last-child { display: inline; }
.dashboard-nav__icon { width: 18px; }
.dashboard-main { padding: 1.5rem 1rem 3rem; }
.dashboard-header { align-items: flex-start; flex-direction: column; gap: 1rem; }
.dashboard-header__actions, .dashboard-header__actions .btn-primary { width: 100%; }
.dashboard-header__actions .btn-primary { justify-content: center; }
.dashboard-metrics { grid-template-columns: 1fr; }
.metric-card { min-height: 128px; }
.dashboard-panel__header { align-items: flex-start; }
.portal-table { min-width: 730px; }
.form-grid--2 { grid-template-columns: 1fr; }
.profile-avatar-field { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.form-grid--2,
.portal-detail-grid {
@@ -4270,3 +4600,13 @@ a.citation-count-badge:hover {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: .01ms !important;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

+36
View File
@@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 360" role="img" aria-label="Tecvico imaging workspace">
<defs>
<linearGradient id="spectral-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#112038"/>
<stop offset="100%" stop-color="#0a1628"/>
</linearGradient>
<linearGradient id="spectral-hot" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#009e82" stop-opacity="0.2"/>
<stop offset="50%" stop-color="#22d3ee" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#8b7cf6" stop-opacity="0.45"/>
</linearGradient>
<pattern id="spectral-grid" width="24" height="24" patternUnits="userSpaceOnUse">
<path d="M24 0H0V24" fill="none" stroke="rgba(255,255,255,0.04)" stroke-width="1"/>
</pattern>
</defs>
<rect width="560" height="360" fill="url(#spectral-bg)"/>
<rect width="560" height="360" fill="url(#spectral-grid)"/>
<rect x="24" y="24" width="160" height="312" rx="8" fill="#0f2847" stroke="rgba(255,255,255,0.08)"/>
<rect x="40" y="48" width="128" height="12" rx="4" fill="rgba(255,255,255,0.08)"/>
<rect x="40" y="72" width="96" height="8" rx="3" fill="rgba(255,255,255,0.05)"/>
<rect x="40" y="88" width="112" height="8" rx="3" fill="rgba(255,255,255,0.05)"/>
<rect x="40" y="120" width="128" height="80" rx="4" fill="rgba(0,201,167,0.12)" stroke="rgba(0,201,167,0.25)"/>
<rect x="40" y="212" width="128" height="80" rx="4" fill="rgba(139,124,246,0.1)" stroke="rgba(139,124,246,0.2)"/>
<rect x="204" y="24" width="332" height="220" rx="8" fill="#060e1a" stroke="rgba(255,255,255,0.08)"/>
<ellipse cx="370" cy="134" rx="72" ry="88" fill="url(#spectral-hot)"/>
<ellipse cx="370" cy="134" rx="48" ry="58" fill="none" stroke="#00c9a7" stroke-width="2" stroke-dasharray="6 4"/>
<line x1="204" y1="256" x2="536" y2="256" stroke="rgba(255,255,255,0.06)"/>
<rect x="220" y="272" width="140" height="56" rx="6" fill="#112038" stroke="rgba(255,255,255,0.06)"/>
<rect x="376" y="272" width="144" height="56" rx="6" fill="#112038" stroke="rgba(255,255,255,0.06)"/>
<text x="290" y="296" text-anchor="middle" fill="#64748b" font-family="IBM Plex Mono, monospace" font-size="10">GLCM</text>
<text x="290" y="312" text-anchor="middle" fill="#00c9a7" font-family="IBM Plex Mono, monospace" font-size="11">0.847</text>
<text x="448" y="296" text-anchor="middle" fill="#64748b" font-family="IBM Plex Mono, monospace" font-size="10">SUV</text>
<text x="448" y="312" text-anchor="middle" fill="#22d3ee" font-family="IBM Plex Mono, monospace" font-size="11">4.2</text>
<rect x="204" y="340" width="332" height="4" rx="2" fill="rgba(255,255,255,0.06)"/>
<rect x="204" y="340" width="198" height="4" rx="2" fill="#00c9a7"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+19
View File
@@ -353,6 +353,24 @@ function initProjectFilters() {
});
}
function initAccountMenu() {
const accountMenu = document.querySelector('.navbar-account-menu');
if (!accountMenu) return;
document.addEventListener('click', (event) => {
if (accountMenu.open && !accountMenu.contains(event.target)) {
accountMenu.removeAttribute('open');
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && accountMenu.open) {
accountMenu.removeAttribute('open');
accountMenu.querySelector('summary')?.focus();
}
});
}
function init() {
initNavbarScroll();
initMobileMenu();
@@ -362,6 +380,7 @@ function init() {
initReadMore();
initCitationCopy();
initProjectFilters();
initAccountMenu();
}
if (document.readyState === 'loading') {
+47 -47
View File
@@ -1,64 +1,64 @@
{% extends "base.html" %}
{% extends "portal_base.html" %}
{% load static %}
{% block title %}Profile{% endblock %}
{% block title %}Profile & Account{% endblock %}
{% block portal_title %}Profile &amp; account{% endblock %}
{% block portal_subtitle %}Build a complete professional profile so Tecvico can understand your work and communicate clearly.{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="profile-heading">
<div class="container">
<div class="page-hero-content">
<h1 class="page-hero-title" id="profile-heading">Your Profile</h1>
<p class="page-hero-subtitle">Keep your contact details up to date for project communication.</p>
</div>
</div>
</section>
<section class="section">
<div class="container container--narrow">
<div class="glass-card portal-card fade-in">
<form method="post" novalidate>
{% block portal_content %}
<div class="dashboard-form-layout">
<section class="dashboard-panel dashboard-form-card" aria-labelledby="profile-details-title">
<div class="dashboard-panel__header"><div><h2 id="profile-details-title">Professional profile</h2><p>Your profile information is visible only to you and the Tecvico team.</p></div></div>
<form method="post" enctype="multipart/form-data" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}<div class="form-errors">{{ form.non_field_errors }}</div>{% endif %}
<div class="profile-avatar-field form-group{% if form.avatar.errors %} has-error{% endif %}">
<div class="profile-avatar-preview" aria-hidden="true">
<img src="{% if user.client_profile.avatar %}{{ user.client_profile.avatar.url }}{% else %}{% static 'images/tecvico/default-avatar.png' %}{% endif %}" alt="" />
</div>
<div>
<label for="id_avatar">Profile picture</label>
{{ form.avatar }}
<p class="form-hint">JPG, PNG, or WebP. Maximum 5 MB. A square image works best.</p>
{{ form.avatar.errors }}
</div>
</div>
<div class="profile-form-section">
<h3>Identity</h3>
<div class="form-grid form-grid--2">
<div class="form-group">
<label for="id_first_name">First name <span class="required-star">*</span></label>
{{ form.first_name }}
{{ form.first_name.errors }}
</div>
<div class="form-group">
<label for="id_last_name">Last name <span class="required-star">*</span></label>
{{ form.last_name }}
{{ form.last_name.errors }}
<div class="form-group"><label for="id_first_name">First name <span class="required-star">*</span></label>{{ form.first_name }}{{ form.first_name.errors }}</div>
<div class="form-group"><label for="id_last_name">Last name <span class="required-star">*</span></label>{{ form.last_name }}{{ form.last_name.errors }}</div>
</div>
<div class="form-group"><label for="id_email">Email address <span class="required-star">*</span></label>{{ form.email }}<p class="form-hint">Used for project updates and secure account communication.</p>{{ form.email.errors }}</div>
</div>
<div class="form-group">
<label for="id_email">Email <span class="required-star">*</span></label>
{{ form.email }}
{{ form.email.errors }}
</div>
<div class="profile-form-section">
<h3>Professional information</h3>
<div class="form-grid form-grid--2">
<div class="form-group">
<label for="id_company">Company</label>
{{ form.company }}
{{ form.company.errors }}
</div>
<div class="form-group">
<label for="id_phone">Phone</label>
{{ form.phone }}
{{ form.phone.errors }}
<div class="form-group"><label for="id_company">Organization</label>{{ form.company }}{{ form.company.errors }}</div>
<div class="form-group"><label for="id_job_title">Role or job title</label>{{ form.job_title }}{{ form.job_title.errors }}</div>
</div>
<div class="form-group"><label for="id_bio">Professional bio</label>{{ form.bio }}<p class="form-hint">Up to 600 characters about your research, technical work, or project interests.</p>{{ form.bio.errors }}</div>
</div>
<div class="form-group">
<label for="id_timezone">Timezone <span class="required-star">*</span></label>
{{ form.timezone }}
{{ form.timezone.errors }}
<div class="profile-form-section">
<h3>Contact details</h3>
<div class="form-group"><label for="id_phone">Phone number</label>{{ form.phone }}{{ form.phone.errors }}</div>
</div>
<button type="submit" class="btn-primary btn-submit">Save profile</button>
<div class="dashboard-form-actions"><button type="submit" class="btn-primary">Save profile</button></div>
</form>
</div>
</div>
</section>
<aside class="profile-context-card">
<div class="profile-context-card__avatar"><img src="{% if user.client_profile.avatar %}{{ user.client_profile.avatar.url }}{% else %}{% static 'images/tecvico/default-avatar.png' %}{% endif %}" alt="" /></div>
<h2>{{ user.get_full_name|default:user.username }}</h2>
{% if user.client_profile.job_title or user.client_profile.company %}<p>{% if user.client_profile.job_title %}{{ user.client_profile.job_title }}{% endif %}{% if user.client_profile.job_title and user.client_profile.company %} · {% endif %}{% if user.client_profile.company %}{{ user.client_profile.company }}{% endif %}</p>{% else %}<p>{{ user.email }}</p>{% endif %}
{% if user.client_profile.bio %}<div class="profile-context-card__expertise">{{ user.client_profile.bio|truncatechars:150 }}</div>{% endif %}
<div class="profile-context-card__rule"></div>
<span class="profile-context-card__label">Account status</span>
<strong class="profile-status"><span></span> Active client account</strong>
</aside>
</div>
{% endblock %}
-6
View File
@@ -67,12 +67,6 @@
</div>
</div>
<div class="form-group">
<label for="id_timezone">Timezone <span class="required-star">*</span></label>
{{ form.timezone }}
{{ form.timezone.errors }}
</div>
<div class="form-grid form-grid--2">
<div class="form-group">
<label for="id_password1">Password <span class="required-star">*</span></label>
+2 -2
View File
@@ -13,7 +13,7 @@
<link rel="stylesheet" href="{% static 'css/main.css' %}" />
{% block extra_css %}{% endblock %}
</head>
<body>
<body class="{% block body_class %}{% endblock %}">
{% include "partials/_navbar.html" %}
@@ -23,7 +23,7 @@
{% block content %}{% endblock %}
</main>
{% include "partials/_footer.html" %}
{% block footer %}{% include "partials/_footer.html" %}{% endblock %}
<script src="{% static 'js/main.js' %}"></script>
{% block extra_js %}{% endblock %}
+4 -4
View File
@@ -149,8 +149,8 @@
<h3 class="modal-title" id="noEmailTitle">No reply address</h3>
<p class="modal-body">You haven't provided an email address. Without it, we won't be able to respond to your message. Would you like to add one, or send without a reply address?</p>
<div class="modal-actions">
<button class="btn-ghost" id="modalAddEmail">Add Email</button>
<button class="btn-primary" id="modalSendAnyway">Send Without Email</button>
<button type="button" class="btn-ghost" id="modalAddEmail">Add Email</button>
<button type="button" class="btn-primary" id="modalSendAnyway">Send Without Email</button>
</div>
</div>
</div>
@@ -164,7 +164,7 @@
</div>
<h3 class="modal-title" id="successTitle">Message Sent!</h3>
<p class="modal-body">Thank you for reaching out. We've received your message and will get back to you as soon as possible.</p>
<button class="btn-primary" id="successClose">Done</button>
<button type="button" class="btn-primary" id="successClose">Done</button>
</div>
</div>
@@ -177,7 +177,7 @@
</div>
<h3 class="modal-title" id="errorTitle">Something Went Wrong</h3>
<p class="modal-body" id="errorModalMsg">Please check your answers and try again.</p>
<button class="btn-ghost" id="errorClose">Try Again</button>
<button type="button" class="btn-ghost" id="errorClose">Try Again</button>
</div>
</div>
+1
View File
@@ -30,6 +30,7 @@
{% for entry in faq_entries %}
<div class="faq-item glass-card fade-in">
<button
type="button"
class="faq-question"
aria-expanded="false"
aria-controls="faq-answer-{{ entry.pk }}"
+1 -1
View File
@@ -1,5 +1,5 @@
{% if site_contact.has_discord %}
<a href="{{ site_contact.discord_url }}" class="{{ button_class|default:'btn-ghost btn-sm' }}" target="_blank" rel="noopener noreferrer">
<a href="{{ site_contact.discord_url }}" class="{{ button_class|default:'btn-ghost btn--sm' }}" target="_blank" rel="noopener noreferrer">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
</svg>
+26 -16
View File
@@ -6,7 +6,7 @@
{% include "partials/_brand_icon.html" with placement="navbar" %}
</a>
<button class="navbar-toggle" id="navbarToggle" aria-expanded="false" aria-controls="navbarMenu" aria-label="Toggle navigation">
<button type="button" class="navbar-toggle" id="navbarToggle" aria-expanded="false" aria-controls="navbarMenu" aria-label="Toggle navigation">
<span class="toggle-bar"></span>
<span class="toggle-bar"></span>
<span class="toggle-bar"></span>
@@ -91,21 +91,31 @@
</li>
{% if user.is_authenticated %}
<li class="nav-item">
<a href="{% url 'projects:dashboard' %}" class="nav-link {% if request.resolver_match.namespace == 'projects' %}active{% endif %}">
My Projects
</a>
</li>
<li class="nav-item">
<a href="{% url 'accounts:profile' %}" class="nav-link {% if request.resolver_match.url_name == 'profile' %}active{% endif %}">
Profile
</a>
</li>
<li class="nav-item">
<form method="post" action="{% url 'accounts:logout' %}" class="nav-logout-form">
{% csrf_token %}
<button type="submit" class="nav-link nav-link-button">Logout</button>
</form>
<li class="nav-item nav-item--account">
<details class="navbar-account-menu">
<summary class="navbar-account" aria-label="Open account menu">
<span class="navbar-account__avatar" aria-hidden="true">
<img src="{% if user.client_profile.avatar %}{{ user.client_profile.avatar.url }}{% else %}{% static 'images/tecvico/default-avatar.png' %}{% endif %}" alt="" />
</span>
<span class="navbar-account__status" aria-hidden="true"></span>
</summary>
<div class="navbar-account-dropdown">
<div class="navbar-account-dropdown__identity">
<img src="{% if user.client_profile.avatar %}{{ user.client_profile.avatar.url }}{% else %}{% static 'images/tecvico/default-avatar.png' %}{% endif %}" alt="" />
<div><strong>{{ user.get_full_name|default:user.username }}</strong><span>{{ user.email }}</span></div>
</div>
<div class="navbar-account-dropdown__section">
<a href="{% url 'projects:dashboard' %}"><span aria-hidden="true"></span><span><strong>Workspace</strong><small>Projects and activity</small></span></a>
<a href="{% url 'accounts:profile' %}"><span aria-hidden="true"></span><span><strong>Your profile</strong><small>Identity and account details</small></span></a>
<a href="{% url 'projects:create' %}"><span aria-hidden="true"></span><span><strong>New project brief</strong><small>Start a scientific project</small></span></a>
</div>
<div class="navbar-account-dropdown__section navbar-account-dropdown__section--compact">
<a href="{% url 'pages:faq' %}">Help center</a>
<a href="{% url 'pages:contact' %}">Contact support</a>
<form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit">Sign out</button></form>
</div>
</div>
</details>
</li>
{% else %}
<li class="nav-item">
+1
View File
@@ -507,6 +507,7 @@
{% else %}
<div class="faq-item glass-card fade-in">
<button
type="button"
class="faq-question"
aria-expanded="false"
aria-controls="faq-answer-{{ section.pk }}-{{ item.pk }}"
+85
View File
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% load static %}
{% block body_class %}portal-body{% endblock %}
{% block content %}
<div class="dashboard-shell">
<aside class="dashboard-sidebar" aria-label="Workspace navigation">
<div class="dashboard-sidebar__profile">
<div class="dashboard-avatar" aria-hidden="true">
<img src="{% if user.client_profile.avatar %}{{ user.client_profile.avatar.url }}{% else %}{% static 'images/tecvico/default-avatar.png' %}{% endif %}" alt="" />
</div>
<div class="dashboard-sidebar__identity">
<strong>{{ user.get_full_name|default:user.username }}</strong>
<span>{{ user.email }}</span>
</div>
</div>
<nav class="dashboard-nav">
<p class="dashboard-nav__label">Workspace</p>
<a href="{% url 'projects:dashboard' %}" class="dashboard-nav__link{% if request.resolver_match.url_name == 'dashboard' or request.resolver_match.url_name == 'detail' %} is-active{% endif %}">
<span class="dashboard-nav__icon" aria-hidden="true"></span>
<span>Overview</span>
</a>
<a href="{% url 'projects:dashboard' %}#project-briefs" class="dashboard-nav__link">
<span class="dashboard-nav__icon" aria-hidden="true"></span>
<span>Project briefs</span>
</a>
<a href="{% url 'projects:create' %}" class="dashboard-nav__link{% if request.resolver_match.url_name == 'create' %} is-active{% endif %}">
<span class="dashboard-nav__icon" aria-hidden="true"></span>
<span>New project brief</span>
</a>
<a href="{% url 'accounts:profile' %}" class="dashboard-nav__link{% if request.resolver_match.url_name == 'profile' %} is-active{% endif %}">
<span class="dashboard-nav__icon" aria-hidden="true"></span>
<span>Profile &amp; account</span>
</a>
<p class="dashboard-nav__label dashboard-nav__label--secondary">Resources</p>
<a href="{% url 'products:overview' %}" class="dashboard-nav__link">
<span class="dashboard-nav__icon" aria-hidden="true"></span>
<span>Scientific products</span>
</a>
<a href="{% url 'pages:faq' %}" class="dashboard-nav__link">
<span class="dashboard-nav__icon" aria-hidden="true">i</span>
<span>Knowledge &amp; FAQ</span>
</a>
<a href="{% url 'pages:contact' %}" class="dashboard-nav__link">
<span class="dashboard-nav__icon" aria-hidden="true">?</span>
<span>Help &amp; support</span>
</a>
<a href="{% url 'pages:home' %}" class="dashboard-nav__link">
<span class="dashboard-nav__icon" aria-hidden="true"></span>
<span>Back to website</span>
</a>
</nav>
<div class="dashboard-sidebar__footer">
<div class="dashboard-security-note">
<span class="dashboard-security-note__mark" aria-hidden="true"></span>
<span><strong>Secure workspace</strong>Your project data is private.</span>
</div>
<form method="post" action="{% url 'accounts:logout' %}">
{% csrf_token %}
<button type="submit" class="dashboard-signout">Sign out</button>
</form>
</div>
</aside>
<main class="dashboard-main">
<header class="dashboard-header">
<div>
<p class="dashboard-eyebrow">Tecvico client workspace</p>
<h1 id="dashboard-page-title">{% block portal_title %}Dashboard{% endblock %}</h1>
<p>{% block portal_subtitle %}{% endblock %}</p>
</div>
<div class="dashboard-header__actions">{% block portal_actions %}{% endblock %}</div>
</header>
<div class="dashboard-content" aria-labelledby="dashboard-page-title">
{% block portal_content %}{% endblock %}
</div>
</main>
</div>
{% endblock %}
{% block footer %}{% endblock %}
+8 -29
View File
@@ -1,33 +1,14 @@
{% extends "base.html" %}
{% extends "portal_base.html" %}
{% block title %}{{ brief.title }}{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="brief-heading">
<div class="container">
<div class="page-hero-content page-hero-content--row">
<div>
{% if brief.category %}<div class="section-badge">{{ brief.get_category_display }}</div>{% endif %}
<h1 class="page-hero-title" id="brief-heading">{{ brief.title }}</h1>
<p class="page-hero-subtitle">
<span class="status-pill status-pill--{{ brief.status }}">{{ brief.get_status_display }}</span>
· Submitted {{ brief.submitted_at|date:"M j, Y" }}
</p>
</div>
<div class="portal-actions">
<a href="{% url 'projects:dashboard' %}" class="btn-ghost">Back to list</a>
{% if brief.is_editable_by_client %}
<a href="{% url 'projects:edit' pk=brief.pk %}" class="btn-primary">Edit brief</a>
{% endif %}
</div>
</div>
</div>
</section>
{% block portal_title %}{{ brief.title }}{% endblock %}
{% block portal_subtitle %}<span class="status-pill status-pill--{{ brief.status }}">{{ brief.get_status_display }}</span> <span class="dashboard-header__meta">Submitted {{ brief.submitted_at|date:"M j, Y" }}</span>{% endblock %}
{% block portal_actions %}<a href="{% url 'projects:dashboard' %}" class="btn-ghost">Back to projects</a>{% if brief.is_editable_by_client %}<a href="{% url 'projects:edit' pk=brief.pk %}" class="btn-primary">Edit brief</a>{% endif %}{% endblock %}
<section class="section">
<div class="container">
{% block portal_content %}
<div class="portal-detail-grid">
<div class="glass-card portal-card fade-in">
<div class="dashboard-panel portal-card">
<h2 class="portal-section-title">Overview</h2>
<dl class="portal-meta-list">
{% if brief.budget_range %}<div><dt>Budget</dt><dd>{{ brief.get_budget_range_display }}</dd></div>{% endif %}
@@ -56,19 +37,17 @@
</div>
{% if brief.shows_quote_to_client %}
<div class="glass-card portal-card fade-in">
<div class="dashboard-panel portal-card">
<h2 class="portal-section-title">Quote from Tecvico</h2>
<p class="portal-body-text">{{ brief.quote_text|linebreaksbr }}</p>
</div>
{% endif %}
{% if brief.has_website_notes %}
<div class="glass-card portal-card fade-in">
<div class="dashboard-panel portal-card">
<h2 class="portal-section-title">Notes from Tecvico</h2>
<p class="portal-body-text">{{ brief.website_notes|linebreaksbr }}</p>
</div>
{% endif %}
</div>
</div>
</section>
{% endblock %}
+17 -15
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "portal_base.html" %}
{% load static %}
{% block title %}{{ form_title }}{% endblock %}
@@ -7,19 +7,12 @@
<link rel="stylesheet" href="{% static 'css/brief-wizard.css' %}" />
{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="brief-form-heading">
<div class="container">
<div class="page-hero-content">
<h1 class="page-hero-title" id="brief-form-heading">{{ form_title }}</h1>
<p class="page-hero-subtitle">Complete each step to submit your project brief.</p>
</div>
</div>
</section>
{% block portal_title %}{{ form_title }}{% endblock %}
{% block portal_subtitle %}Turn your scientific or technical challenge into a clear, review-ready scope.{% endblock %}
<section class="section">
<div class="container container--narrow">
<div class="glass-card portal-card fade-in">
{% block portal_content %}
<div class="dashboard-form-layout dashboard-form-layout--wizard">
<section class="dashboard-panel dashboard-form-card">
<form method="post" enctype="multipart/form-data" novalidate class="brief-wizard" data-brief-wizard data-initial-step="{{ initial_step }}">
{% csrf_token %}
@@ -140,9 +133,18 @@
<button type="submit" class="btn-primary btn-submit" data-wizard-submit hidden>{{ submit_label }}</button>
</div>
</form>
</div>
</div>
</section>
<aside class="brief-guidance-card">
<span class="brief-guidance-card__eyebrow">Before you submit</span>
<h2>A strong brief helps us respond precisely.</h2>
<ul>
<li><span>1</span><div><strong>Define the outcome</strong><p>Focus on the scientific or technical result you need.</p></div></li>
<li><span>2</span><div><strong>Add useful context</strong><p>Include datasets, constraints, methods, or references.</p></div></li>
<li><span>3</span><div><strong>Set expectations</strong><p>Share your preferred timeline and budget range.</p></div></li>
</ul>
<p class="brief-guidance-card__note">You can review every field in the final step before submission.</p>
</aside>
</div>
{% endblock %}
{% block extra_js %}
+39 -49
View File
@@ -1,70 +1,60 @@
{% extends "base.html" %}
{% extends "portal_base.html" %}
{% block title %}My Projects{% endblock %}
{% block title %}Project Dashboard{% endblock %}
{% block portal_title %}Welcome back{% if user.first_name %}, {{ user.first_name }}{% endif %}.{% endblock %}
{% block portal_subtitle %}Manage your research and technology projects from one focused workspace.{% endblock %}
{% block portal_actions %}<a href="{% url 'projects:create' %}" class="btn-primary">Start a new brief <span aria-hidden="true"></span></a>{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="dashboard-heading">
<div class="container">
<div class="page-hero-content page-hero-content--row">
<div>
<div class="section-badge">Client Portal</div>
<h1 class="page-hero-title" id="dashboard-heading">My Projects</h1>
<p class="page-hero-subtitle">Track briefs you've submitted to the Tecvico team.</p>
</div>
<a href="{% url 'projects:create' %}" class="btn-primary">New project brief</a>
</div>
</div>
{% block portal_content %}
<section class="dashboard-metrics" aria-label="Workspace summary">
<article class="metric-card">
<div class="metric-card__top"><span class="metric-card__icon">P</span><span class="metric-card__trend">Portfolio</span></div>
<strong>{{ page_obj.paginator.count|default:0 }}</strong>
<span>Total project brief{{ page_obj.paginator.count|pluralize }}</span>
</article>
<article class="metric-card">
<div class="metric-card__top"><span class="metric-card__icon metric-card__icon--gold">R</span><span class="metric-card__trend">Structured</span></div>
<strong>4 steps</strong>
<span>From scientific scope to delivery</span>
</article>
<article class="metric-card">
<div class="metric-card__top"><span class="metric-card__icon metric-card__icon--green"></span><span class="metric-card__trend">Private</span></div>
<strong>Secure</strong>
<span>Visible only to you and Tecvico</span>
</article>
</section>
<section class="section">
<div class="container">
<section class="dashboard-panel" id="project-briefs" aria-labelledby="recent-projects-title">
<div class="dashboard-panel__header">
<div><h2 id="recent-projects-title">Your project briefs</h2><p>Track submissions, quotes, and delivery progress.</p></div>
{% if briefs %}<span class="dashboard-panel__count">{{ page_obj.paginator.count }} total</span>{% endif %}
</div>
{% if briefs %}
<div class="portal-table-wrap glass-card fade-in">
<div class="portal-table-wrap">
<table class="portal-table">
<thead>
<tr>
<th>Title</th>
<th>Category</th>
<th>Status</th>
<th>Budget</th>
<th>Submitted</th>
<th></th>
</tr>
</thead>
<thead><tr><th>Project</th><th>Field</th><th>Status</th><th>Budget</th><th>Submitted</th><th><span class="sr-only">Actions</span></th></tr></thead>
<tbody>
{% for brief in briefs %}
<tr>
<td>{{ brief.title }}</td>
<td>{% if brief.category %}{{ brief.get_category_display }}{% else %}{% endif %}</td>
<td><a class="project-title-link" href="{% url 'projects:detail' pk=brief.pk %}">{{ brief.title }}</a></td>
<td>{% if brief.category %}{{ brief.get_category_display }}{% else %}<span class="table-muted">Not set</span>{% endif %}</td>
<td><span class="status-pill status-pill--{{ brief.status }}">{{ brief.get_status_display }}</span></td>
<td>{% if brief.budget_range %}{{ brief.get_budget_range_display }}{% else %}{% endif %}</td>
<td>{% if brief.budget_range %}{{ brief.get_budget_range_display }}{% else %}<span class="table-muted">Not set</span>{% endif %}</td>
<td>{{ brief.submitted_at|date:"M j, Y" }}</td>
<td><a href="{% url 'projects:detail' pk=brief.pk %}" class="portal-link">View</a></td>
<td><a href="{% url 'projects:detail' pk=brief.pk %}" class="table-action" aria-label="View {{ brief.title }}">View <span aria-hidden="true"></span></a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.has_other_pages %}
<div class="portal-pagination">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}" class="portal-link">Previous</a>
{% endif %}
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}" class="portal-link">Next</a>
{% endif %}
</div>
{% endif %}
{% if page_obj.has_other_pages %}<div class="portal-pagination">{% if page_obj.has_previous %}<a href="?page={{ page_obj.previous_page_number }}" class="portal-link">Previous</a>{% endif %}<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a href="?page={{ page_obj.next_page_number }}" class="portal-link">Next</a>{% endif %}</div>{% endif %}
{% else %}
<div class="glass-card portal-card portal-empty fade-in">
<h2>No project briefs yet</h2>
<p class="portal-intro">Describe what you need and our team will review your request.</p>
<a href="{% url 'projects:create' %}" class="btn-primary">Submit your first brief</a>
<div class="portal-empty">
<div class="portal-empty__symbol" aria-hidden="true">+</div>
<h3>Create your first project brief</h3>
<p>Define the scientific challenge, goals, timeline, and supporting materials. You can review everything before submitting.</p>
<a href="{% url 'projects:create' %}" class="btn-primary">Create project brief</a>
</div>
{% endif %}
</div>
</section>
{% endblock %}