first commit
CI/CD / CI · dotnet build (push) Failing after 0s
CI/CD / Deploy · drsousan (push) Has been skipped

This commit is contained in:
soroush.asadi
2026-05-31 00:42:08 +03:30
commit 96e73bf633
39 changed files with 8144 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "Dr Sousan Website",
"runtimeExecutable": "npx",
"runtimeArgs": ["serve", "-p", "3500", "F:/Projects/DrSousan"],
"port": 3500
},
{
"name": "Dr Sousan API",
"runtimeExecutable": "dotnet",
"runtimeArgs": ["run", "-c", "Release", "--no-launch-profile", "--project", "F:/Projects/DrSousan/DrSousan.Api"],
"port": 5199
}
]
}
+17
View File
@@ -0,0 +1,17 @@
**/.git
**/.vs
**/.vscode
**/bin
**/obj
**/*.user
**/*.suo
**/node_modules
**/*.db
**/*.db-journal
**/*.db-shm
**/*.db-wal
**/uploads/*
!**/uploads/.gitkeep
.env
.env.*
**/seed_posts.js
+14
View File
@@ -0,0 +1,14 @@
# Copy this file to .env and fill in your production values.
# docker compose reads .env automatically from the project root.
# Host port (default 5000 → http://localhost:5000)
HOST_PORT=5000
# JWT secret — must be at least 32 characters, keep it secret!
JWT_KEY=ReplaceThisWithALongSecureRandomStringAtLeast32Chars!
JWT_ISSUER=DrSousanApi
JWT_AUDIENCE=DrSousanAdmin
# Admin panel credentials
ADMIN_USERNAME=admin
ADMIN_PASSWORD=ChangeThisToAStrongPassword!
+113
View File
@@ -0,0 +1,113 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: drsousan-cicd-${{ github.ref }}
cancel-in-progress: true
# ─────────────────────────────────────────────────────────────────────────────
# HOW THIS WORKS
# ─────────────────────────────────────────────────────────────────────────────
# Runner labels:
# ubuntu-latest → container runner ← CI dotnet build runs here
# self-hosted → host runner ← docker build/push/deploy runs here
#
# Local Nexus:
# Docker registry → 171.22.25.73:8087 (mcr-proxy + drsousan images)
# NuGet → 171.22.25.73:8081/repository/nuget-group/
#
# Required Gitea secrets:
# ENV_FILE → contents of .env
# REGISTRY_PASSWORD → Nexus admin password
# ─────────────────────────────────────────────────────────────────────────────
jobs:
# ── CI: compile-check (runs on every push / PR) ──────────────────────────────
ci:
name: "CI · dotnet build"
runs-on: ubuntu-latest
container:
image: 171.22.25.73:8087/dotnet/sdk:10.0
options: --add-host=gitea:host-gateway
steps:
- name: Checkout
env:
TOKEN: ${{ github.token }}
REF: ${{ github.ref }}
run: |
git init
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git config http.extraheader "Authorization: Bearer ${TOKEN}"
git fetch --depth=1 origin "${REF}"
git checkout FETCH_HEAD
- name: Restore
working-directory: DrSousan.Api
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
run: dotnet restore DrSousan.Api.csproj
- name: Build
working-directory: DrSousan.Api
run: dotnet build DrSousan.Api.csproj --no-restore -c Release
# ── Deploy: build image on server → run (push to main only) ────────────────
deploy:
name: "Deploy · drsousan"
runs-on: self-hosted
env:
PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
needs: [ci]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
timeout-minutes: 20
steps:
- name: Checkout
env:
TOKEN: ${{ github.token }}
REF: ${{ github.ref }}
run: |
git init
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git config http.extraheader "Authorization: Bearer ${TOKEN}"
git fetch --depth=1 origin "${REF}"
git checkout FETCH_HEAD
- name: Write .env
run: printf '%s' "$ENV_FILE" > .env
env:
ENV_FILE: ${{ secrets.ENV_FILE }}
- name: Build image
run: docker compose build api
env:
DOCKER_BUILDKIT: 1
- name: Deploy
run: docker compose up -d --no-deps api
- name: Wait for healthy
run: |
for i in $(seq 1 24); do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' drsousan_api 2>/dev/null || echo "missing")
echo " [$i/24] $STATUS"
[ "$STATUS" = "healthy" ] && echo "✅ drsousan_api healthy" && exit 0
sleep 5
done
echo "❌ timed out"
docker compose logs --tail=60 api
exit 1
- name: Show containers
if: always()
run: docker compose ps
- name: Prune old images
if: success()
run: docker image prune -f
+31
View File
@@ -0,0 +1,31 @@
# Secrets
.env
# Build output
**/bin/
**/obj/
# SQLite databases
*.db
*.db-shm
*.db-wal
# Uploaded images
DrSousan.Api/wwwroot/uploads/*
!DrSousan.Api/wwwroot/uploads/.gitkeep
# IDE
.vs/
.vscode/
*.user
*.suo
# OS
.DS_Store
Thumbs.db
# Node
node_modules/
# Logs
*.log
+152
View File
@@ -0,0 +1,152 @@
# DrSousan — Deployment Guide
## Mirrors (Nexus at `171.22.25.73`)
### Docker
Add to Docker Engine config (`/etc/docker/daemon.json` on Linux):
```json
{
"insecure-registries": ["171.22.25.73:8087", "171.22.25.73:8090"]
}
```
Restart Docker, then login:
```bash
docker login 171.22.25.73:8087 -u admin
```
### NuGet (for local builds)
Add nexus source to `NuGet.Config`:
```xml
<add key="nexus" value="http://171.22.25.73:8081/repository/nuget-group/index.json" />
```
---
## Update the Dockerfile to use Nexus mirrors
Replace the two `FROM` lines in `DrSousan.Api/Dockerfile`:
```dockerfile
FROM 171.22.25.73:8090/dotnet/sdk:10.0 AS build
...
FROM 171.22.25.73:8090/dotnet/aspnet:10.0 AS runtime
```
---
## First Deploy (server setup)
```bash
# 1. Copy project to server
scp -r . user@server:/opt/drsousan
# 2. SSH in
ssh user@server
cd /opt/drsousan
# 3. Create .env file
cp .env.example .env
nano .env # fill in JWT_KEY, ADMIN_USERNAME, ADMIN_PASSWORD, HOST_PORT
# 4. Build and start
docker compose up -d --build
```
Check it's running:
```bash
docker compose ps
curl http://localhost:5000/healthz
```
---
## Redeploy (update)
```bash
cd /opt/drsousan
git pull
docker compose up -d --build
```
That's it. SQLite data and uploads are on named volumes — they survive rebuilds.
---
## .env file
```env
HOST_PORT=5000
JWT_KEY=YourSecretKeyHere32CharsMinimum!!
JWT_ISSUER=DrSousanApi
JWT_AUDIENCE=DrSousanAdmin
ADMIN_USERNAME=admin
ADMIN_PASSWORD=YourStrongPassword
```
---
## CI/CD (GitHub Actions)
Create `.github/workflows/deploy.yml`:
```yaml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build & push image
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 171.22.25.73:8087 -u admin --password-stdin
docker build \
--build-arg REGISTRY=171.22.25.73:8090 \
-t 171.22.25.73:8087/drsousan/api:latest \
./DrSousan.Api
docker push 171.22.25.73:8087/drsousan/api:latest
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /opt/drsousan
docker pull 171.22.25.73:8087/drsousan/api:latest
docker compose up -d
```
**GitHub Secrets to set:**
| Secret | Value |
|--------|-------|
| `REGISTRY_PASSWORD` | Nexus admin password |
| `SERVER_HOST` | Server IP |
| `SERVER_USER` | SSH user |
| `SERVER_SSH_KEY` | Private SSH key |
For the CI image pull to use Nexus, update `docker-compose.yml` to reference the pre-built image:
```yaml
api:
image: 171.22.25.73:8087/drsousan/api:latest
# remove build: section when using CI
```
---
## Useful commands
```bash
docker compose logs -f api # live logs
docker compose restart api # restart without rebuild
docker compose down # stop (volumes preserved)
docker compose down -v # stop + DELETE all data
docker exec -it drsousan_api sh # shell into container
```
+14
View File
@@ -0,0 +1,14 @@
**/.vs
**/.vscode
**/bin
**/obj
**/*.user
**/*.db
**/*.db-shm
**/*.db-wal
**/uploads
**/node_modules
**/.git
**/.gitignore
**/Dockerfile*
**/*.md
+37
View File
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using DrSousan.Api.Models;
namespace DrSousan.Api.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
public DbSet<Service> Services => Set<Service>();
public DbSet<GalleryItem> GalleryItems => Set<GalleryItem>();
public DbSet<Testimonial> Testimonials => Set<Testimonial>();
public DbSet<BlogCategory> BlogCategories => Set<BlogCategory>();
public DbSet<BlogPost> BlogPosts => Set<BlogPost>();
public DbSet<Comment> Comments => Set<Comment>();
public DbSet<Faq> Faqs => Set<Faq>();
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<SiteSetting>()
.HasIndex(s => new { s.Section, s.Key })
.IsUnique();
mb.Entity<BlogPost>()
.HasIndex(b => b.Slug)
.IsUnique();
mb.Entity<BlogCategory>()
.HasIndex(c => c.Slug)
.IsUnique();
mb.Entity<Comment>()
.HasOne(c => c.Parent)
.WithMany(c => c.Replies)
.HasForeignKey(c => c.ParentId)
.OnDelete(DeleteBehavior.Cascade);
}
}
+44
View File
@@ -0,0 +1,44 @@
# ── Stage 1: Build ────────────────────────────────────────────────────────────
FROM 171.22.25.73:8087/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore dependencies first (layer-cache friendly)
COPY DrSousan.Api.csproj NuGet.Config ./
RUN ok=0; \
for i in 1 2 3 4 5; do \
dotnet restore DrSousan.Api.csproj --configfile NuGet.Config && ok=1 && break; \
echo "Restore attempt $i failed, retrying in 30s..."; sleep 30; \
done; \
[ "$ok" = "1" ]
# Copy everything and publish
COPY . .
RUN dotnet publish DrSousan.Api.csproj \
-c Release \
-o /app/publish \
--no-restore
# ── Stage 2: Runtime ──────────────────────────────────────────────────────────
FROM 171.22.25.73:8087/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# Create directories for persistent volumes and set ownership
# .NET 10 aspnet image ships with a built-in non-root user "app" (uid 1654)
RUN mkdir -p /data /app/wwwroot/uploads
COPY --from=build /app/publish .
EXPOSE 8080
# /data → SQLite DB (mount as named volume)
# /app/wwwroot/uploads → uploaded images (mount as named volume)
VOLUME ["/data", "/app/wwwroot/uploads"]
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
# Self-probe via the app's own runtime — the aspnet image has no curl/wget.
HEALTHCHECK --interval=15s --timeout=10s --start-period=30s --retries=3 \
CMD ["dotnet", "DrSousan.Api.dll", "--healthcheck"]
ENTRYPOINT ["dotnet", "DrSousan.Api.dll"]
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RazorLangVersion>latest</RazorLangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.*" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@DrSousan.Api_HostAddress = http://localhost:5199
GET {{DrSousan.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
+137
View File
@@ -0,0 +1,137 @@
using System.ComponentModel.DataAnnotations;
namespace DrSousan.Api.Models;
// ─── Site Settings (key-value store per section) ─────────────────────────────
public class SiteSetting
{
public int Id { get; set; }
[MaxLength(100)] public string Key { get; set; } = "";
public string Value { get; set; } = "";
[MaxLength(50)] public string Section { get; set; } = "";
}
// ─── Service card ─────────────────────────────────────────────────────────────
public class Service
{
public int Id { get; set; }
[MaxLength(200)] public string Title { get; set; } = "";
public string Description { get; set; } = "";
[MaxLength(500)] public string IconSvg { get; set; } = "";
[MaxLength(500)] public string BeforeImageUrl { get; set; } = "";
[MaxLength(500)] public string AfterImageUrl { get; set; } = "";
public int Order { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// ─── Gallery item ─────────────────────────────────────────────────────────────
public class GalleryItem
{
public int Id { get; set; }
[MaxLength(500)] public string ImageUrl { get; set; } = "";
[MaxLength(500)] public string BeforeImageUrl { get; set; } = "";
[MaxLength(500)] public string AfterImageUrl { get; set; } = "";
[MaxLength(100)] public string Category { get; set; } = "";
[MaxLength(300)] public string Caption { get; set; } = "";
public int Order { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// ─── Testimonial ──────────────────────────────────────────────────────────────
public class Testimonial
{
public int Id { get; set; }
[MaxLength(100)] public string AuthorName { get; set; } = "";
[MaxLength(10)] public string AuthorEmoji { get; set; } = "👩";
public string Text { get; set; } = "";
public int Rating { get; set; } = 5;
[MaxLength(50)] public string Date { get; set; } = "";
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// ─── Blog category ────────────────────────────────────────────────────────────
public class BlogCategory
{
public int Id { get; set; }
[MaxLength(100)] public string Name { get; set; } = "";
[MaxLength(120)] public string Slug { get; set; } = "";
public string Description { get; set; } = "";
public ICollection<BlogPost> Posts { get; set; } = new List<BlogPost>();
}
// ─── Blog post ────────────────────────────────────────────────────────────────
public class BlogPost
{
public int Id { get; set; }
// Content
[MaxLength(300)] public string Title { get; set; } = "";
[MaxLength(320)] public string Slug { get; set; } = "";
public string Excerpt { get; set; } = "";
public string Content { get; set; } = "";
[MaxLength(500)] public string FeaturedImage { get; set; } = "";
[MaxLength(100)] public string Author { get; set; } = "دکتر سوسن آل‌طه";
// SEO
[MaxLength(70)] public string MetaTitle { get; set; } = "";
[MaxLength(160)] public string MetaDescription { get; set; } = "";
[MaxLength(100)] public string FocusKeyword { get; set; } = "";
public string Keywords { get; set; } = ""; // comma-separated
[MaxLength(500)] public string OgImage { get; set; } = "";
// Schema.org
[MaxLength(50)] public string ArticleType { get; set; } = "MedicalWebPage";
// Status
public bool IsPublished { get; set; } = false;
public DateTime? PublishedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Metrics
public int ViewCount { get; set; } = 0;
public int ReadingTimeMinutes { get; set; } = 5;
// Relations
public int? CategoryId { get; set; }
public BlogCategory? Category { get; set; }
}
// ─── Blog comment ────────────────────────────────────────────────────────────
public class Comment
{
public int Id { get; set; }
[MaxLength(100)] public string AuthorName { get; set; } = "";
[MaxLength(200)] public string AuthorEmail { get; set; } = "";
public string Body { get; set; } = "";
public bool IsApproved { get; set; } = false;
public bool IsAdminReply { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Post relation
public int BlogPostId { get; set; }
public BlogPost? Post { get; set; }
// Self-referential reply
public int? ParentId { get; set; }
public Comment? Parent { get; set; }
public ICollection<Comment> Replies { get; set; } = new List<Comment>();
}
// ─── FAQ ─────────────────────────────────────────────────────────────────────
public class Faq
{
public int Id { get; set; }
public string Question { get; set; } = "";
public string Answer { get; set; } = "";
public int Order { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// ─── DTOs ─────────────────────────────────────────────────────────────────────
public record LoginRequest(string Username, string Password);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record SettingDto(string Key, string Value);
public record BulkSettingsDto(Dictionary<string, string> Settings);
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org"
value="https://api.nuget.org/v3/index.json"
protocolVersion="3" />
</packageSources>
</configuration>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nexus"
value="http://171.22.25.73:8081/repository/nuget-group/index.json"
protocolVersion="3"
allowInsecureConnections="true" />
</packageSources>
</configuration>
+116
View File
@@ -0,0 +1,116 @@
@page "/blog"
@model DrSousan.Api.Pages.Blog.BlogIndexModel
@section Head {
<title>@ViewData["Title"]</title>
<meta name="description" content="مقالات تخصصی دکتر سوسن آل‌طه درباره زیبایی پوست، بوتاکس، فیلر، لیزر و مراقبت از پوست." />
<link rel="canonical" href="@(Request.Scheme + "://" + Request.Host + "/blog")" />
<style>
/* ─── Blog Hero ─────────────────────────────────────────────── */
.blog-hero{background:linear-gradient(135deg,var(--gold-pale) 0%,#EDE0CA 100%);padding:6rem 2rem 3rem;text-align:center}
.blog-hero h1{font-size:clamp(1.8rem,4vw,2.5rem);font-weight:700;color:var(--dark);margin-bottom:.6rem}
.blog-hero p{font-size:1rem;color:var(--mid);max-width:520px;margin:0 auto}
/* ─── Search ─────────────────────────────────────────────────── */
.search-wrap{max-width:480px;margin:1.5rem auto 0;position:relative}
.search-wrap input{width:100%;border:1.5px solid var(--border);border-radius:50px;padding:.65rem 1.2rem .65rem 3rem;font-family:'Vazirmatn',sans-serif;font-size:.9rem;direction:rtl;outline:none;background:var(--white);transition:border-color .2s}
.search-wrap input:focus{border-color:var(--gold)}
.search-wrap svg{position:absolute;left:1rem;top:50%;transform:translateY(-50%);width:18px;height:18px;color:var(--light)}
/* ─── Filter ─────────────────────────────────────────────────── */
.filter-bar{max-width:1100px;margin:2rem auto 0;padding:0 2rem;display:flex;gap:.6rem;flex-wrap:wrap}
.filter-btn{background:transparent;border:1.5px solid var(--border);color:var(--mid);padding:.4rem 1.1rem;border-radius:50px;font-family:'Vazirmatn',sans-serif;font-size:.85rem;cursor:pointer;transition:all .2s;text-decoration:none;display:inline-block}
.filter-btn.active,.filter-btn:hover{background:var(--gold);border-color:var(--gold);color:var(--white)}
/* ─── Blog Grid ──────────────────────────────────────────────── */
.blog-grid{max-width:1100px;margin:2rem auto;padding:0 2rem;display:grid;grid-template-columns:repeat(3,1fr);gap:1.5rem}
@@media(max-width:900px){.blog-grid{grid-template-columns:repeat(2,1fr)}}
@@media(max-width:600px){.blog-grid{grid-template-columns:1fr}}
.post-card{background:var(--white);border-radius:16px;border:1px solid var(--border);overflow:hidden;transition:transform .3s,box-shadow .3s;display:flex;flex-direction:column}
.post-card:hover{transform:translateY(-4px);box-shadow:0 12px 40px rgba(184,149,90,.15)}
.post-card-img{aspect-ratio:16/9;background:linear-gradient(135deg,var(--gold-pale),#EDE0CA);display:flex;align-items:center;justify-content:center;color:var(--gold);font-size:2rem;overflow:hidden}
.post-card-img img{width:100%;height:100%;object-fit:cover}
.post-card-body{padding:1.3rem;flex:1;display:flex;flex-direction:column;gap:.6rem}
.post-cat{font-size:.72rem;font-weight:600;color:var(--gold);background:var(--gold-pale);padding:.2rem .7rem;border-radius:50px;display:inline-block}
.post-title{font-size:1rem;font-weight:600;color:var(--dark);line-height:1.5}
.post-title:hover{color:var(--gold)}
.post-excerpt{font-size:.85rem;color:var(--mid);line-height:1.7;flex:1}
.post-meta{display:flex;align-items:center;justify-content:space-between;font-size:.75rem;color:var(--light);margin-top:auto;padding-top:.6rem;border-top:1px solid var(--border)}
.read-more{color:var(--gold);font-weight:500;font-size:.82rem}
/* ─── Empty ──────────────────────────────────────────────────── */
.empty{text-align:center;padding:4rem 2rem;color:var(--light);grid-column:1/-1}
/* ─── Pagination ─────────────────────────────────────────────── */
.pagination{display:flex;gap:.5rem;justify-content:center;padding:2rem;margin-top:1rem;max-width:1100px;margin-left:auto;margin-right:auto}
.page-btn{width:38px;height:38px;border-radius:8px;border:1.5px solid var(--border);background:transparent;font-family:'Vazirmatn',sans-serif;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;text-decoration:none;color:var(--dark);font-size:.9rem}
.page-btn.active,.page-btn:hover{background:var(--gold);border-color:var(--gold);color:var(--white)}
</style>
}
<div class="blog-hero">
<h1>وبلاگ تخصصی پوست و زیبایی</h1>
<p>آخرین مقالات و راهنماهای تخصصی درباره مراقبت از پوست، زیبایی و درمان‌های تخصصی</p>
<div class="search-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="searchInput" placeholder="جستجو در مقالات..." />
</div>
</div>
<div class="filter-bar">
<a href="/blog" class="filter-btn @(string.IsNullOrEmpty(Model.ActiveCat) ? "active" : "")">همه</a>
@foreach (var cat in Model.Categories)
{
var active = Model.ActiveCat == cat.Slug;
var count = cat.Posts.Count(p => p.IsPublished);
<a href="/blog?category=@cat.Slug" class="filter-btn @(active ? "active" : "")">@cat.Name (@count)</a>
}
</div>
<div class="blog-grid" id="blogGrid">
@if (!Model.Posts.Any())
{
<div class="empty"><p>مقاله‌ای یافت نشد.</p></div>
}
else
{
@foreach (var post in Model.Posts)
{
<div class="post-card">
<div class="post-card-img">
@if (!string.IsNullOrEmpty(post.FeaturedImage))
{
<img src="@post.FeaturedImage" alt="@post.Title" loading="lazy" />
}
else { <span>📝</span> }
</div>
<div class="post-card-body">
@if (post.Category != null)
{
<span class="post-cat">@post.Category.Name</span>
}
<a href="/blog/@post.Slug" class="post-title">@post.Title</a>
<p class="post-excerpt">@(post.Excerpt.Length > 120 ? post.Excerpt.Substring(0, 120) + "..." : post.Excerpt)</p>
<div class="post-meta">
<span>🕐 @post.ReadingTimeMinutes دقیقه | 👁 @post.ViewCount</span>
<a href="/blog/@post.Slug" class="read-more">ادامه مطلب ←</a>
</div>
</div>
</div>
}
}
</div>
@if (Model.TotalPages > 1)
{
<div class="pagination">
@if (Model.CurrentPage > 1)
{
<a class="page-btn" href="/blog?page=@(Model.CurrentPage - 1)@(!string.IsNullOrEmpty(Model.ActiveCat) ? "&category=" + Model.ActiveCat : "")"></a>
}
@for (int p = 1; p <= Model.TotalPages; p++)
{
<a class="page-btn @(p == Model.CurrentPage ? "active" : "")"
href="/blog?page=@p@(!string.IsNullOrEmpty(Model.ActiveCat) ? "&category=" + Model.ActiveCat : "")">@p</a>
}
@if (Model.CurrentPage < Model.TotalPages)
{
<a class="page-btn" href="/blog?page=@(Model.CurrentPage + 1)@(!string.IsNullOrEmpty(Model.ActiveCat) ? "&category=" + Model.ActiveCat : "")"></a>
}
</div>
}
+60
View File
@@ -0,0 +1,60 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using DrSousan.Api.Data;
using DrSousan.Api.Models;
namespace DrSousan.Api.Pages.Blog;
public class BlogIndexModel : PageModel
{
private readonly AppDbContext _db;
private const int PageSize = 10;
public BlogIndexModel(AppDbContext db) => _db = db;
public List<BlogPost> Posts { get; private set; } = new();
public List<BlogCategory> Categories { get; private set; } = new();
public int CurrentPage { get; private set; } = 1;
public int TotalPages { get; private set; } = 1;
public int TotalPosts { get; private set; } = 0;
public string? ActiveCat { get; private set; }
public async Task<IActionResult> OnGetAsync(int page = 1, string? category = null)
{
CurrentPage = page < 1 ? 1 : page;
ActiveCat = category;
var q = _db.BlogPosts.Include(p => p.Category).Where(p => p.IsPublished);
if (!string.IsNullOrEmpty(category))
q = q.Where(p => p.Category != null && p.Category.Slug == category);
TotalPosts = await q.CountAsync();
TotalPages = Math.Max(1, (int)Math.Ceiling(TotalPosts / (double)PageSize));
if (CurrentPage > TotalPages) CurrentPage = TotalPages;
Posts = await q
.OrderByDescending(p => p.PublishedAt)
.Skip((CurrentPage - 1) * PageSize)
.Take(PageSize)
.ToListAsync();
Categories = await _db.BlogCategories
.Include(c => c.Posts)
.ToListAsync();
ViewData["SiteName"] = await GetSiteNameAsync();
ViewData["Title"] = "وبلاگ | دکتر سوسن آل‌طه";
return Page();
}
private async Task<string> GetSiteNameAsync()
{
var s = await _db.SiteSettings
.FirstOrDefaultAsync(x => x.Section == "hero" && x.Key == "name");
return s?.Value ?? "دکتر سوسن آل‌طه";
}
}
+290
View File
@@ -0,0 +1,290 @@
@page "/blog/{slug}"
@model DrSousan.Api.Pages.Blog.PostModel
@{
var post = Model.Post!;
var baseUrl = Request.Scheme + "://" + Request.Host;
var canonicalUrl = baseUrl + "/blog/" + post.Slug;
var ogImage = ViewData["OgImage"]?.ToString() ?? "";
var articleType = ViewData["ArticleType"]?.ToString() ?? "MedicalWebPage";
var pubDate = post.PublishedAt?.ToString("yyyy-MM-ddTHH:mm:ssZ") ?? "";
var updDate = post.UpdatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ");
}
@section Head {
<title>@ViewData["Title"]</title>
<meta name="description" content="@ViewData["MetaDesc"]" />
@if (!string.IsNullOrEmpty(ViewData["Keywords"]?.ToString())) {
<meta name="keywords" content="@ViewData["Keywords"]" />
}
<meta property="og:type" content="article" />
<meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@ViewData["MetaDesc"]" />
<meta property="og:url" content="@canonicalUrl" />
<meta property="og:locale" content="fa_IR" />
@if (!string.IsNullOrEmpty(ogImage)) {
<meta property="og:image" content="@(ogImage.StartsWith("http") ? ogImage : baseUrl + ogImage)" />
}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@ViewData["Title"]" />
<meta name="twitter:description" content="@ViewData["MetaDesc"]" />
<link rel="canonical" href="@canonicalUrl" />
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "@articleType",
"headline": "@post.Title.Replace("\"","\\\"") ",
"description": "@(ViewData["MetaDesc"]?.ToString()?.Replace("\"","\\\""))",
"author": { "@@type": "Person", "name": "@post.Author" },
"publisher": {
"@@type": "Organization",
"name": "@ViewData["SiteName"]",
"url": "@baseUrl"
},
"datePublished": "@pubDate",
"dateModified": "@updDate",
"mainEntityOfPage": "@canonicalUrl"
@if (!string.IsNullOrEmpty(ogImage)) {
@:,"image": "@(ogImage.StartsWith("http") ? ogImage : baseUrl + ogImage)"
}
}
</script>
<style>
/* ─── Post Layout ──────────────────────────────────────────────── */
.post-layout{max-width:1100px;margin:0 auto;padding:5rem 2rem 3rem;display:grid;grid-template-columns:1fr 320px;gap:3rem;align-items:start}
@@media(max-width:900px){.post-layout{grid-template-columns:1fr;padding-top:5rem}}
/* ─── Article ──────────────────────────────────────────────────── */
.article-hero{border-radius:16px;overflow:hidden;margin-bottom:2rem;aspect-ratio:16/6;background:linear-gradient(135deg,var(--gold-pale),#EDE0CA);display:flex;align-items:center;justify-content:center;font-size:3rem}
.article-hero img{width:100%;height:100%;object-fit:cover}
.article-cat{display:inline-block;background:var(--gold-pale);color:var(--gold);font-size:.75rem;font-weight:600;padding:.25rem .8rem;border-radius:50px;margin-bottom:.8rem}
.article-title{font-size:clamp(1.5rem,3vw,2rem);font-weight:700;line-height:1.4;margin-bottom:1rem}
.article-meta{display:flex;gap:1.5rem;font-size:.8rem;color:var(--light);margin-bottom:2rem;padding-bottom:1.5rem;border-bottom:1px solid var(--border);flex-wrap:wrap}
.article-meta span{display:flex;align-items:center;gap:.3rem}
/* ─── Content ──────────────────────────────────────────────────── */
.article-content{font-size:.95rem;line-height:2;color:var(--dark)}
.article-content h2{font-size:1.3rem;font-weight:700;margin:1.8rem 0 .8rem;color:var(--dark);padding-bottom:.4rem;border-bottom:2px solid var(--gold-pale)}
.article-content h3{font-size:1.1rem;font-weight:600;margin:1.4rem 0 .6rem;color:var(--dark)}
.article-content p{margin-bottom:1rem}
.article-content ul,.article-content ol{padding-right:1.5rem;margin-bottom:1rem}
.article-content li{margin-bottom:.4rem}
.article-content strong{color:var(--dark);font-weight:600}
.article-content a{color:var(--gold);border-bottom:1px solid var(--gold-pale)}
.article-content blockquote{border-right:4px solid var(--gold);padding:.8rem 1.2rem;background:var(--gold-pale);border-radius:0 8px 8px 0;margin:1.2rem 0;font-style:italic;color:var(--mid)}
/* ─── Tags ─────────────────────────────────────────────────────── */
.article-tags{margin-top:2rem;padding-top:1.5rem;border-top:1px solid var(--border);display:flex;gap:.5rem;flex-wrap:wrap}
.tag{background:var(--bg);border:1px solid var(--border);padding:.25rem .75rem;border-radius:50px;font-size:.78rem;color:var(--mid)}
/* ─── Share ─────────────────────────────────────────────────────── */
.share-box{margin-top:2rem;padding:1.5rem;background:var(--white);border-radius:12px;border:1px solid var(--border);text-align:center}
.share-title{font-size:.9rem;font-weight:600;margin-bottom:1rem}
.share-btns{display:flex;gap:.6rem;justify-content:center;flex-wrap:wrap}
.share-btn{padding:.45rem 1rem;border-radius:8px;font-family:'Vazirmatn',sans-serif;font-size:.82rem;cursor:pointer;border:none;font-weight:500}
.share-telegram{background:#2CA5E0;color:#fff}
.share-whatsapp{background:#25D366;color:#fff}
.share-copy{background:var(--bg);color:var(--mid);border:1px solid var(--border)}
/* ─── CTA ──────────────────────────────────────────────────────── */
.cta-box{margin-top:2.5rem;padding:2rem;background:linear-gradient(135deg,var(--gold-pale),#EDE0CA);border-radius:16px;text-align:center}
.cta-box h3{font-size:1.1rem;font-weight:700;margin-bottom:.6rem}
.cta-box p{font-size:.88rem;color:var(--mid);margin-bottom:1.2rem}
.cta-btn{background:var(--gold);color:#fff;padding:.7rem 1.8rem;border-radius:50px;font-family:'Vazirmatn',sans-serif;font-size:.9rem;font-weight:600;border:none;cursor:pointer;display:inline-block;text-decoration:none}
/* ─── Sidebar ──────────────────────────────────────────────────── */
.sidebar{position:sticky;top:90px}
.sidebar-card{background:var(--white);border-radius:14px;border:1px solid var(--border);padding:1.4rem;margin-bottom:1.5rem}
.sidebar-title{font-size:.88rem;font-weight:700;color:var(--dark);margin-bottom:1rem;padding-bottom:.6rem;border-bottom:1px solid var(--border)}
.recent-post{display:flex;gap:.8rem;margin-bottom:.9rem;padding-bottom:.9rem;border-bottom:1px solid var(--border)}
.recent-post:last-child{margin-bottom:0;padding-bottom:0;border-bottom:none}
.recent-img{width:56px;height:56px;border-radius:8px;background:var(--gold-pale);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:1.3rem;overflow:hidden}
.recent-img img{width:100%;height:100%;object-fit:cover;border-radius:8px}
.recent-title{font-size:.82rem;font-weight:600;line-height:1.4;color:var(--dark);text-decoration:none}
.recent-title:hover{color:var(--gold)}
.recent-date{font-size:.73rem;color:var(--light);margin-top:.2rem}
.doctor-card{text-align:center}
.doc-avatar{width:80px;height:80px;border-radius:50%;background:var(--gold-pale);margin:0 auto .8rem;display:flex;align-items:center;justify-content:center;font-size:2rem}
.doc-name{font-size:.95rem;font-weight:700;color:var(--dark)}
.doc-title{font-size:.78rem;color:var(--light);margin:.2rem 0 .8rem}
.doc-btn{background:var(--gold);color:#fff;padding:.5rem 1.2rem;border-radius:50px;font-family:'Vazirmatn',sans-serif;font-size:.82rem;border:none;cursor:pointer;width:100%;text-decoration:none;display:block;text-align:center}
/* ─── Comments ─────────────────────────────────────────────────── */
.comments-section{margin-top:3rem}
.comments-section h2{font-size:1.3rem;font-weight:700;margin-bottom:1.5rem;color:var(--dark)}
.comment-card{background:var(--gold-pale);border-radius:12px;padding:1.2rem;margin-bottom:1rem;opacity:.98}
.comment-card.admin-reply{background:rgba(184,149,90,0.12);border-right:3px solid var(--gold)}
.comment-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem}
.comment-author{font-weight:600;font-size:.92rem;color:var(--dark)}
.comment-author.admin{color:var(--gold)}
.comment-date{font-size:.78rem;color:var(--light)}
.comment-body{font-size:.88rem;color:var(--mid);line-height:1.8}
.comment-replies{margin-top:1rem;margin-right:1.5rem}
.comment-form-wrap{margin-top:2rem;background:var(--section-bg);border-radius:16px;padding:1.75rem;border:1px solid var(--border)}
.comment-form-wrap h3{font-size:1rem;font-weight:700;margin-bottom:1.2rem;color:var(--dark)}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.form-group{display:flex;flex-direction:column;gap:.4rem;margin-bottom:1rem}
.form-group label{font-size:.85rem;font-weight:500;color:var(--dark)}
.form-group input,.form-group textarea{background:var(--white);border:1.5px solid var(--border);border-radius:10px;padding:.65rem .9rem;font-family:'Vazirmatn',sans-serif;font-size:.88rem;color:var(--dark);direction:rtl;outline:none;transition:border-color .2s}
.form-group input:focus,.form-group textarea:focus{border-color:var(--gold)}
.form-group textarea{resize:vertical;min-height:100px}
.form-success{display:none;background:#e6f4ea;color:#1e7e34;border-radius:8px;padding:.8rem 1rem;text-align:center;margin-bottom:1rem;font-size:.88rem}
.form-success.show{display:block}
@@media(max-width:600px){.form-row{grid-template-columns:1fr}}
</style>
}
<div class="post-layout">
<!-- ── Article ── -->
<article>
<div class="article-hero">
@if (!string.IsNullOrEmpty(post.FeaturedImage))
{
<img src="@post.FeaturedImage" alt="@post.Title" loading="lazy" />
}
else { <span>📝</span> }
</div>
@if (post.Category != null)
{
<span class="article-cat">@post.Category.Name</span>
}
<h1 class="article-title">@post.Title</h1>
<div class="article-meta">
<span>✍️ @post.Author</span>
@if (post.PublishedAt.HasValue)
{
<span>📅 @post.PublishedAt.Value.ToString("yyyy/MM/dd")</span>
}
<span>🕐 @post.ReadingTimeMinutes دقیقه مطالعه</span>
<span>👁 @post.ViewCount بازدید</span>
</div>
<div class="article-content">
@Html.Raw(post.Content)
</div>
@if (!string.IsNullOrEmpty(post.Keywords))
{
<div class="article-tags">
@foreach (var kw in post.Keywords.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
<span class="tag">@kw.Trim()</span>
}
</div>
}
<div class="share-box">
<div class="share-title">این مقاله را به اشتراک بگذارید</div>
<div class="share-btns">
<a class="share-btn share-telegram" href="https://t.me/share/url?url=@Uri.EscapeDataString(canonicalUrl)&text=@Uri.EscapeDataString(post.Title)" target="_blank" rel="noopener">📱 تلگرام</a>
<a class="share-btn share-whatsapp" href="https://wa.me/?text=@Uri.EscapeDataString(post.Title + " " + canonicalUrl)" target="_blank" rel="noopener">💬 واتساپ</a>
<button class="share-btn share-copy" onclick="navigator.clipboard.writeText('@canonicalUrl').then(()=>{this.textContent='✓ کپی شد!';setTimeout(()=>this.textContent='🔗 کپی لینک',2000)})">🔗 کپی لینک</button>
</div>
</div>
<div class="cta-box">
<h3>آماده تحول در پوست‌تان هستید؟</h3>
<p>همین امروز با دکتر سوسن آل‌طه مشاوره رایگان دریافت کنید</p>
<a href="/#contact" class="cta-btn">رزرو نوبت رایگان</a>
</div>
<!-- Comments -->
<section class="comments-section" id="comments">
<h2>نظرات (@Model.Comments.Count)</h2>
@if (!Model.Comments.Any())
{
<p style="color:var(--light);margin-bottom:2rem;">اولین نظر را ثبت کنید!</p>
}
else
{
@foreach (var comment in Model.Comments)
{
<div class="comment-card @(comment.IsAdminReply ? "admin-reply" : "")">
<div class="comment-header">
<span class="comment-author @(comment.IsAdminReply ? "admin" : "")">
@(comment.IsAdminReply ? "👩‍⚕️ " : "")@comment.AuthorName
</span>
<span class="comment-date">@comment.CreatedAt.ToString("yyyy/MM/dd")</span>
</div>
<p class="comment-body">@comment.Body</p>
@if (comment.Replies.Any())
{
<div class="comment-replies">
@foreach (var reply in comment.Replies)
{
<div class="comment-card admin-reply">
<div class="comment-header">
<span class="comment-author admin">👩‍⚕️ @reply.AuthorName</span>
<span class="comment-date">@reply.CreatedAt.ToString("yyyy/MM/dd")</span>
</div>
<p class="comment-body">@reply.Body</p>
</div>
}
</div>
}
</div>
}
}
<div class="comment-form-wrap">
<h3>ثبت نظر</h3>
@if (Model.CommentSent)
{
<div class="form-success show">✅ نظر شما ثبت شد و پس از تأیید نمایش داده می‌شود.</div>
}
<form method="post" asp-page="/Blog/Post" asp-route-slug="@post.Slug">
<div class="form-row">
<div class="form-group">
<label asp-for="CommentAuthor">نام *</label>
<input asp-for="CommentAuthor" placeholder="نام شما" />
<span asp-validation-for="CommentAuthor" style="color:red;font-size:.78rem;"></span>
</div>
<div class="form-group">
<label asp-for="CommentEmail">ایمیل (اختیاری)</label>
<input asp-for="CommentEmail" placeholder="ایمیل شما" dir="ltr" />
</div>
</div>
<div class="form-group">
<label asp-for="CommentBody">نظر *</label>
<textarea asp-for="CommentBody" placeholder="نظر خود را بنویسید..."></textarea>
<span asp-validation-for="CommentBody" style="color:red;font-size:.78rem;"></span>
</div>
<button type="submit" class="btn-primary">ارسال نظر</button>
</form>
</div>
</section>
</article>
<!-- ── Sidebar ── -->
<aside class="sidebar">
<div class="sidebar-card doctor-card">
<div class="doc-avatar">👩‍⚕️</div>
<div class="doc-name">@post.Author</div>
<div class="doc-title">پزشک عمومی | متخصص زیبایی پوست</div>
<a href="/#contact" class="doc-btn">رزرو نوبت</a>
</div>
<div class="sidebar-card">
<div class="sidebar-title">دسته‌بندی</div>
@if (post.Category != null)
{
<a href="/blog?category=@post.Category.Slug" style="color:var(--gold);font-size:.9rem;">@post.Category.Name ←</a>
}
</div>
@if (!string.IsNullOrEmpty(post.FocusKeyword))
{
<div class="sidebar-card">
<div class="sidebar-title">کلیدواژه اصلی</div>
<span style="background:var(--gold-pale);padding:.3rem .7rem;border-radius:12px;font-size:.82rem;color:var(--mid);">@post.FocusKeyword</span>
</div>
}
<div class="sidebar-card">
<div class="sidebar-title">خدمات ما</div>
<div style="display:flex;flex-direction:column;gap:.5rem;font-size:.85rem">
<a href="/#services" style="color:var(--mid);display:flex;align-items:center;gap:.4rem"><span style="color:var(--gold)">←</span> بوتاکس و فیلر</a>
<a href="/#services" style="color:var(--mid);display:flex;align-items:center;gap:.4rem"><span style="color:var(--gold)">←</span> لیزر درمانی</a>
<a href="/#services" style="color:var(--mid);display:flex;align-items:center;gap:.4rem"><span style="color:var(--gold)">←</span> مزوتراپی</a>
<a href="/#services" style="color:var(--mid);display:flex;align-items:center;gap:.4rem"><span style="color:var(--gold)">←</span> پاکسازی پوست</a>
<a href="/#services" style="color:var(--mid);display:flex;align-items:center;gap:.4rem"><span style="color:var(--gold)">←</span> مشاوره زیبایی</a>
</div>
</div>
</aside>
</div>
+153
View File
@@ -0,0 +1,153 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using DrSousan.Api.Data;
using DrSousan.Api.Models;
namespace DrSousan.Api.Pages.Blog;
public class PostModel : PageModel
{
private readonly AppDbContext _db;
public PostModel(AppDbContext db) => _db = db;
public BlogPost? Post { get; private set; }
public List<CommentVm> Comments { get; private set; } = new();
public bool CommentSent { get; private set; } = false;
// Comment form binding
[BindProperty]
[Required(ErrorMessage = "نام الزامی است.")]
[MaxLength(100)]
public string CommentAuthor { get; set; } = "";
[BindProperty]
[MaxLength(200)]
[EmailAddress]
public string CommentEmail { get; set; } = "";
[BindProperty]
[Required(ErrorMessage = "متن نظر الزامی است.")]
public string CommentBody { get; set; } = "";
public async Task<IActionResult> OnGetAsync(string slug)
{
var post = await _db.BlogPosts
.Include(p => p.Category)
.FirstOrDefaultAsync(p => p.Slug == slug && p.IsPublished);
if (post is null) return NotFound();
// Increment view count
post.ViewCount++;
await _db.SaveChangesAsync();
Post = post;
await LoadCommentsAsync(post.Id);
await SetViewDataAsync(post);
return Page();
}
public async Task<IActionResult> OnPostAsync(string slug)
{
var post = await _db.BlogPosts
.Include(p => p.Category)
.FirstOrDefaultAsync(p => p.Slug == slug && p.IsPublished);
if (post is null) return NotFound();
Post = post;
await LoadCommentsAsync(post.Id);
await SetViewDataAsync(post);
if (!ModelState.IsValid) return Page();
// Sanitise
var body = System.Text.RegularExpressions.Regex.Replace(CommentBody ?? "", "<[^>]*>", "").Trim();
var authorName = System.Text.RegularExpressions.Regex.Replace(CommentAuthor ?? "", "<[^>]*>", "").Trim();
if (string.IsNullOrWhiteSpace(body) || string.IsNullOrWhiteSpace(authorName))
{
ModelState.AddModelError("", "نام و متن نظر الزامی است.");
return Page();
}
_db.Comments.Add(new Comment
{
BlogPostId = post.Id,
AuthorName = authorName,
AuthorEmail = CommentEmail ?? "",
Body = body,
IsApproved = false,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
CommentSent = true;
// Clear form
CommentAuthor = "";
CommentEmail = "";
CommentBody = "";
ModelState.Clear();
return Page();
}
private async Task LoadCommentsAsync(int postId)
{
var raw = await _db.Comments
.Where(c => c.BlogPostId == postId && c.IsApproved && c.ParentId == null)
.OrderBy(c => c.CreatedAt)
.Select(c => new
{
c.Id, c.AuthorName, c.Body, c.CreatedAt, c.IsAdminReply,
Replies = _db.Comments
.Where(r => r.ParentId == c.Id && r.IsApproved)
.OrderBy(r => r.CreatedAt)
.Select(r => new { r.Id, r.AuthorName, r.Body, r.CreatedAt, r.IsAdminReply })
.ToList()
}).ToListAsync();
Comments = raw.Select(c => new CommentVm
{
Id = c.Id, AuthorName = c.AuthorName, Body = c.Body,
CreatedAt = c.CreatedAt, IsAdminReply = c.IsAdminReply,
Replies = c.Replies.Select(r => new CommentVm
{
Id = r.Id, AuthorName = r.AuthorName, Body = r.Body,
CreatedAt = r.CreatedAt, IsAdminReply = r.IsAdminReply
}).ToList()
}).ToList();
}
private async Task SetViewDataAsync(BlogPost post)
{
var metaTitle = string.IsNullOrEmpty(post.MetaTitle) ? post.Title : post.MetaTitle;
var metaDesc = string.IsNullOrEmpty(post.MetaDescription) ? post.Excerpt : post.MetaDescription;
// Avoid duplicating the site name if MetaTitle already contains it
var suffix = " | دکتر سوسن آل‌طه";
ViewData["Title"] = metaTitle.Contains("دکتر سوسن") ? metaTitle : metaTitle + suffix;
ViewData["MetaDesc"] = metaDesc;
ViewData["Keywords"] = post.Keywords;
ViewData["OgImage"] = string.IsNullOrEmpty(post.OgImage) ? post.FeaturedImage : post.OgImage;
ViewData["ArticleType"] = post.ArticleType;
ViewData["Slug"] = post.Slug;
var s = await _db.SiteSettings
.FirstOrDefaultAsync(x => x.Section == "hero" && x.Key == "name");
ViewData["SiteName"] = s?.Value ?? "دکتر سوسن آل‌طه";
}
// View model for comments
public class CommentVm
{
public int Id { get; set; }
public string AuthorName { get; set; } = "";
public string Body { get; set; } = "";
public DateTime CreatedAt { get; set; }
public bool IsAdminReply { get; set; }
public List<CommentVm> Replies { get; set; } = new();
}
}
@@ -0,0 +1,4 @@
@using DrSousan.Api.Models
@using DrSousan.Api.Data
@namespace DrSousan.Api.Pages.Blog
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+742
View File
@@ -0,0 +1,742 @@
@page
@model IndexModel
@{
var h = Model.Hero;
var a = Model.About;
var c = Model.Contact;
var siteName = h.GetValueOrDefault("name", "دکتر سوسن آل‌طه");
var heroImg = h.GetValueOrDefault("image", "");
var badgeHidden = h.GetValueOrDefault("badge_hidden", "") == "true";
var badgeTitle = h.TryGetValue("badge_title", out var _bt) && !string.IsNullOrWhiteSpace(_bt) ? _bt : "متخصص پوست و زیبایی";
var badgeSub = h.TryGetValue("badge_subtitle", out var _bs) && !string.IsNullOrWhiteSpace(_bs) ? _bs : "فارغ‌التحصیل دانشگاه ایران";
var aboutImg = a.GetValueOrDefault("image", "");
var yearsExp = a.GetValueOrDefault("years_exp","۱۰+");
var ig = c.GetValueOrDefault("instagram","");
var wa = c.GetValueOrDefault("whatsapp","");
var tg = c.GetValueOrDefault("telegram","");
}
@section Head {
<title>@ViewData["Title"]</title>
<meta name="description" content="@h.GetValueOrDefault("subtitle","")" />
<meta name="keywords" content="دکتر پوست تهران,بوتاکس,لیزر موهای زائد,فیلر,مزوتراپی,زیبایی پوست" />
<meta property="og:type" content="website" />
<meta property="og:title" content="@ViewData["Title"]" />
<meta property="og:description" content="@h.GetValueOrDefault("subtitle","")" />
<meta property="og:locale" content="fa_IR" />
<link rel="canonical" href="@(Request.Scheme + "://" + Request.Host + "/")" />
<script type="application/ld+json">
{
"@@context":"https://schema.org",
"@@type":["MedicalBusiness","LocalBusiness"],
"name":"@siteName",
"description":"@h.GetValueOrDefault("subtitle","")",
"url":"@(Request.Scheme + "://" + Request.Host)",
"telephone":"@c.GetValueOrDefault("phone","")",
"address":{"@@type":"PostalAddress","addressLocality":"تهران","addressCountry":"IR","streetAddress":"@c.GetValueOrDefault("address","")"},
"openingHours":"@c.GetValueOrDefault("hours","")",
"medicalSpecialty":"Dermatology"
}
</script>
<style>
/* ─── Hero ─────────────────────────────────────────────────── */
#hero { min-height:100svh; display:flex; align-items:center; padding:100px 0 3rem; position:relative; overflow:hidden; }
#hero::before { content:''; position:absolute; top:-120px; left:-120px; width:600px; height:600px; background:radial-gradient(circle, rgba(184,149,90,0.10) 0%, transparent 70%); pointer-events:none; }
#hero::after { content:''; position:absolute; bottom:-80px; right:-80px; width:500px; height:500px; background:radial-gradient(circle, rgba(184,149,90,0.07) 0%, transparent 70%); pointer-events:none; }
.hero-inner { max-width:1200px; margin:0 auto; padding:0 2rem; display:grid; grid-template-columns:1fr 1fr; gap:4rem; align-items:center; }
.hero-text { animation: fadeUp 0.9s ease both; }
.hero-tag { display:inline-block; background:var(--gold-pale); color:var(--gold); font-size:0.78rem; font-weight:500; padding:0.35rem 1rem; border-radius:50px; margin-bottom:1.4rem; letter-spacing:0.05em; }
.hero-name { font-size:clamp(2.2rem,4vw,3.2rem); font-weight:700; line-height:1.3; color:var(--dark); margin-bottom:0.6rem; }
.hero-name span { color:var(--gold); }
.hero-subtitle { font-size:1.05rem; color:var(--mid); font-weight:400; margin-bottom:1.8rem; line-height:1.9; }
.hero-stats { display:flex; gap:2.5rem; margin-bottom:2.5rem; }
.stat-item { text-align:center; }
.stat-number { font-size:2rem; font-weight:700; color:var(--gold); display:block; line-height:1; }
.stat-label { font-size:0.78rem; color:var(--light); margin-top:0.3rem; }
.hero-actions { display:flex; gap:1rem; flex-wrap:wrap; }
.hero-image { position:relative; animation:fadeUp 0.9s 0.2s ease both; }
.hero-image-frame { width:100%; aspect-ratio:4/5; border-radius:30% 70% 70% 30% / 30% 30% 70% 70%; background:linear-gradient(145deg, var(--gold-pale) 0%, #EFE3CC 100%); position:relative; overflow:hidden; box-shadow:0 30px 80px rgba(184,149,90,0.2); }
.hero-image-frame img { width:100%; height:100%; object-fit:cover; }
.hero-image-placeholder { width:100%; height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1rem; color:var(--gold); }
.hero-image-placeholder svg { width:80px; height:80px; opacity:0.5; }
.hero-image-placeholder p { font-size:0.85rem; opacity:0.6; }
.hero-badge { position:absolute; bottom:2rem; left:-2rem; background:var(--white); border-radius:16px; padding:1rem 1.4rem; box-shadow:0 10px 40px rgba(0,0,0,0.1); display:flex; align-items:center; gap:0.8rem; animation:float 3s ease-in-out infinite; }
.badge-icon { width:42px; height:42px; background:var(--gold-pale); border-radius:50%; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.badge-icon svg { width:20px; height:20px; color:var(--gold); }
.badge-text { font-size:0.82rem; line-height:1.4; }
.badge-text strong { color:var(--gold); display:block; font-size:0.9rem; }
/* ─── About ─────────────────────────────────────────────────── */
#about { background:var(--white); }
.about-grid { display:grid; grid-template-columns:1fr 1fr; gap:5rem; align-items:center; }
.about-image-wrap { position:relative; }
.about-img-box { width:100%; aspect-ratio:3/4; border-radius:20px; background:linear-gradient(160deg, var(--gold-pale) 0%, #EDE0CA 100%); overflow:hidden; position:relative; }
.about-img-box img { width:100%; height:100%; object-fit:cover; }
.about-img-placeholder { width:100%; height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1rem; color:var(--gold); opacity:0.5; }
.about-img-placeholder svg { width:60px; height:60px; }
.about-accent-box { position:absolute; bottom:-2rem; right:-2rem; background:var(--gold); color:var(--white); border-radius:16px; padding:1.5rem 2rem; text-align:center; }
.about-accent-box .big { font-size:2.2rem; font-weight:700; display:block; }
.about-accent-box .small { font-size:0.82rem; opacity:0.85; }
.credential-list { list-style:none; display:flex; flex-direction:column; gap:1rem; margin-top:2rem; }
.credential-list li { display:flex; align-items:flex-start; gap:0.85rem; font-size:0.95rem; color:var(--mid); line-height:1.6; }
.cred-icon { width:28px; height:28px; border-radius:50%; background:var(--gold-pale); display:flex; align-items:center; justify-content:center; flex-shrink:0; margin-top:0.1rem; }
.cred-icon svg { width:13px; height:13px; color:var(--gold); }
/* ─── Services ─────────────────────────────────────────────── */
#services { background:var(--section-bg); }
.services-header { text-align:center; margin-bottom:3.5rem; }
.services-header .divider { margin:1.2rem auto 0; }
.services-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:1.5rem; }
.service-card { background:var(--white); border-radius:20px; padding:2rem; border:1px solid var(--border); transition:transform 0.3s, box-shadow 0.3s, border-color 0.3s; position:relative; overflow:hidden; }
.service-card::before { content:''; position:absolute; top:0; right:0; width:80px; height:80px; background:radial-gradient(circle at top right, rgba(184,149,90,0.08), transparent 70%); }
.service-card:hover { transform:translateY(-6px); box-shadow:0 20px 50px rgba(184,149,90,0.15); border-color:var(--gold-pale); }
.service-icon { width:56px; height:56px; background:var(--gold-pale); border-radius:16px; display:flex; align-items:center; justify-content:center; margin-bottom:1.3rem; }
.service-icon svg { width:26px; height:26px; color:var(--gold); }
.service-title { font-size:1.05rem; font-weight:600; color:var(--dark); margin-bottom:0.6rem; }
.service-desc { font-size:0.88rem; color:var(--mid); line-height:1.8; }
/* ─── Service Before/After ────────────────────────────────── */
.svc-ba { margin-top:1.2rem; border-radius:12px; overflow:hidden; position:relative; aspect-ratio:4/3; cursor:pointer; user-select:none; }
.svc-ba img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; transition:opacity 0.45s ease; }
.svc-ba .ba-after { opacity:0; }
.svc-ba.show-after .ba-before { opacity:0; }
.svc-ba.show-after .ba-after { opacity:1; }
.svc-ba-btns { position:absolute; bottom:0; left:0; right:0; display:flex; z-index:2; }
.svc-ba-btn { flex:1; padding:0.45rem 0; text-align:center; font-size:0.78rem; font-weight:700; font-family:'Vazirmatn',sans-serif; border:none; cursor:pointer; transition:all 0.25s; background:rgba(0,0,0,0.42); color:rgba(255,255,255,0.75); letter-spacing:0.03em; }
.svc-ba-btn.active { background:var(--gold); color:#fff; }
.svc-ba-only { margin-top:1.2rem; border-radius:12px; overflow:hidden; position:relative; aspect-ratio:4/3; }
.svc-ba-only img { width:100%; height:100%; object-fit:cover; display:block; }
.svc-ba-only .ba-solo-label { position:absolute; bottom:0; left:0; right:0; padding:0.4rem; text-align:center; font-size:0.78rem; font-weight:700; font-family:'Vazirmatn',sans-serif; background:var(--gold); color:#fff; }
/* ─── Gallery ──────────────────────────────────────────────── */
#gallery { background:var(--white); }
.gallery-header { margin-bottom:3rem; }
.gallery-tabs { display:flex; gap:0.6rem; margin-bottom:2.5rem; flex-wrap:wrap; }
.tab-btn { background:transparent; border:1.5px solid var(--border); color:var(--mid); padding:0.45rem 1.2rem; border-radius:50px; font-family:'Vazirmatn',sans-serif; font-size:0.85rem; cursor:pointer; transition:all 0.25s; }
.tab-btn.active, .tab-btn:hover { background:var(--gold); border-color:var(--gold); color:var(--white); }
.gallery-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:1.2rem; }
.gallery-item { border-radius:16px; overflow:hidden; aspect-ratio:4/3; background:linear-gradient(135deg, var(--gold-pale), #EDE0CA); position:relative; cursor:pointer; }
.gallery-item img { width:100%; height:100%; object-fit:cover; transition:transform 0.4s; }
.gallery-item:hover img { transform:scale(1.05); }
.gallery-placeholder { width:100%; height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:0.6rem; color:var(--gold); opacity:0.45; }
.gallery-placeholder svg { width:36px; height:36px; }
.gallery-placeholder p { font-size:0.75rem; }
.gallery-item-overlay { position:absolute; inset:0; background:rgba(184,149,90,0); display:flex; align-items:center; justify-content:center; transition:background 0.3s; }
.gallery-item:hover .gallery-item-overlay { background:rgba(184,149,90,0.15); }
/* ─── Testimonials ─────────────────────────────────────────── */
#testimonials { background:var(--section-bg); }
.testimonials-header { text-align:center; margin-bottom:3rem; }
.testimonials-header .divider { margin:1rem auto 0; }
.testimonials-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:1.5rem; }
.testimonial-card { background:var(--white); border-radius:20px; padding:2rem; border:1px solid var(--border); position:relative; }
.testimonial-card::before { content:'"'; position:absolute; top:1rem; left:1.5rem; font-size:4rem; color:var(--gold-pale); line-height:1; font-family:Georgia,serif; }
.testimonial-stars { display:flex; gap:3px; margin-bottom:1rem; }
.star { color:var(--gold); font-size:1rem; }
.testimonial-text { font-size:0.9rem; color:var(--mid); line-height:1.9; margin-bottom:1.5rem; }
.testimonial-author { display:flex; align-items:center; gap:0.8rem; }
.author-avatar { width:42px; height:42px; border-radius:50%; background:var(--gold-pale); display:flex; align-items:center; justify-content:center; font-size:1.1rem; flex-shrink:0; }
.author-name { font-size:0.88rem; font-weight:600; color:var(--dark); }
.author-date { font-size:0.78rem; color:var(--light); }
/* ─── Blog Preview ─────────────────────────────────────────── */
#blog-preview { background:var(--white); }
.blog-preview-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:1.5rem; }
.post-card-prev { background:var(--bg); border-radius:16px; border:1px solid var(--border); overflow:hidden; transition:transform 0.3s,box-shadow 0.3s; display:flex; flex-direction:column; }
.post-card-prev:hover { transform:translateY(-4px); box-shadow:0 12px 40px rgba(184,149,90,0.15); }
.post-card-prev-img { aspect-ratio:16/9; background:linear-gradient(135deg,var(--gold-pale),#EDE0CA); display:flex; align-items:center; justify-content:center; color:var(--gold); font-size:2rem; overflow:hidden; }
.post-card-prev-img img { width:100%; height:100%; object-fit:cover; }
.post-card-prev-body { padding:1.3rem; flex:1; display:flex; flex-direction:column; gap:0.6rem; }
.post-cat-label { font-size:0.72rem; font-weight:600; color:var(--gold); background:var(--gold-pale); padding:0.2rem 0.7rem; border-radius:50px; display:inline-block; }
.post-card-prev-title { font-size:1rem; font-weight:600; color:var(--dark); line-height:1.5; }
.post-card-prev-title:hover { color:var(--gold); }
.post-card-prev-excerpt { font-size:0.85rem; color:var(--mid); line-height:1.7; flex:1; }
.post-card-prev-meta { display:flex; align-items:center; justify-content:space-between; font-size:0.75rem; color:var(--light); margin-top:auto; padding-top:0.6rem; border-top:1px solid var(--border); }
.read-more-link { color:var(--gold); font-weight:500; font-size:0.82rem; }
.blog-preview-more { text-align:center; margin-top:2.5rem; }
/* ─── FAQ ──────────────────────────────────────────────────── */
#faq { background:var(--section-bg); }
.faq-list { max-width:780px; margin:0 auto; display:flex; flex-direction:column; gap:0.8rem; }
.faq-item { background:var(--white); border-radius:12px; border:1px solid var(--border); overflow:hidden; }
.faq-item summary { padding:1.25rem 1.5rem; cursor:pointer; font-weight:600; list-style:none; display:flex; justify-content:space-between; align-items:center; color:var(--dark); font-size:0.95rem; }
.faq-item summary::-webkit-details-marker { display:none; }
.faq-item summary::after { content:""; color:var(--gold); font-size:1.1rem; flex-shrink:0; margin-right:1rem; }
.faq-item[open] summary::after { content:""; }
.faq-answer { padding:0 1.5rem 1.25rem; color:var(--mid); font-size:0.92rem; line-height:1.85; border-top:1px solid var(--border); }
/* ─── Contact ──────────────────────────────────────────────── */
#contact { background:var(--white); }
.contact-grid { display:grid; grid-template-columns:1fr 1.4fr; gap:4rem; align-items:start; }
.contact-info-list { display:flex; flex-direction:column; gap:1.5rem; margin-top:2rem; }
.contact-info-item { display:flex; align-items:flex-start; gap:1rem; }
.info-icon { width:48px; height:48px; background:var(--gold-pale); border-radius:14px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.info-icon svg { width:22px; height:22px; color:var(--gold); }
.info-text strong { display:block; font-size:0.9rem; color:var(--dark); margin-bottom:0.2rem; }
.info-text p { font-size:0.88rem; color:var(--mid); line-height:1.6; }
.social-links { display:flex; gap:0.8rem; margin-top:2.5rem; }
.social-link { width:44px; height:44px; border-radius:12px; background:var(--gold-pale); display:flex; align-items:center; justify-content:center; color:var(--gold); text-decoration:none; transition:background 0.25s, transform 0.2s; }
.social-link:hover { background:var(--gold); color:var(--white); transform:translateY(-3px); }
.social-link svg { width:20px; height:20px; }
.contact-form { background:var(--section-bg); border-radius:24px; padding:2.5rem; border:1px solid var(--border); }
.form-title { font-size:1.2rem; font-weight:600; color:var(--dark); margin-bottom:0.5rem; }
.form-sub { font-size:0.88rem; color:var(--mid); margin-bottom:2rem; }
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:1rem; }
.form-group { display:flex; flex-direction:column; gap:0.4rem; margin-bottom:1.2rem; }
.form-group label { font-size:0.85rem; font-weight:500; color:var(--dark); }
.form-group input, .form-group select, .form-group textarea { background:var(--white); border:1.5px solid var(--border); border-radius:12px; padding:0.75rem 1rem; font-family:'Vazirmatn',sans-serif; font-size:0.9rem; color:var(--dark); direction:rtl; transition:border-color 0.25s, box-shadow 0.25s; outline:none; }
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color:var(--gold); box-shadow:0 0 0 3px rgba(184,149,90,0.12); }
.form-group textarea { resize:vertical; min-height:110px; }
.form-submit { width:100%; background:var(--gold); color:var(--white); border:none; padding:0.9rem; border-radius:12px; font-family:'Vazirmatn',sans-serif; font-size:0.95rem; font-weight:600; cursor:pointer; transition:background 0.25s, transform 0.2s; }
.form-submit:hover { background:var(--gold-light); transform:translateY(-2px); }
/* ─── Responsive ───────────────────────────────────────────── */
@@media (max-width:900px) {
.hero-inner { grid-template-columns:1fr; text-align:center; gap:2.5rem; }
.hero-text { order:1; }
.hero-image { order:2; max-width:320px; margin:0 auto; }
.hero-stats { justify-content:center; gap:2rem; }
.hero-actions { justify-content:center; flex-wrap:wrap; gap:.8rem; }
.hero-badge { left:0; right:0; margin:0 auto; width:fit-content; }
.about-grid { grid-template-columns:1fr; gap:3rem; }
.about-accent-box { right:0; bottom:-1rem; }
.services-grid { grid-template-columns:repeat(2,1fr); }
.gallery-grid { grid-template-columns:repeat(2,1fr); }
.testimonials-grid { grid-template-columns:1fr; }
.contact-grid { grid-template-columns:1fr; gap:2.5rem; }
.blog-preview-grid { grid-template-columns:repeat(2,1fr); }
}
@@media (max-width:600px) {
section { padding:3.5rem 0; }
.container { padding:0 1.2rem; }
.hero-inner { padding:0 1.2rem; gap:2rem; }
.hero-image { max-width:260px; }
.hero-stats { gap:1.5rem; }
.hero-stats .stat-number { font-size:1.6rem; }
.hero-badge { position:static; margin-top:1rem; width:auto; border-radius:12px; padding:.7rem 1rem; }
.services-grid { grid-template-columns:1fr; }
.gallery-grid { grid-template-columns:repeat(2,1fr); gap:.8rem; }
.blog-preview-grid { grid-template-columns:1fr; }
.form-row { grid-template-columns:1fr; }
.about-accent-box { position:static; border-radius:12px; margin-top:1.5rem; display:inline-block; }
.about-image-wrap { text-align:center; }
.testimonials-grid { grid-template-columns:1fr; }
.section-title { font-size:1.5rem; }
.faq-item summary { font-size:.9rem; padding:1rem 1.2rem; }
.faq-answer { padding:0 1.2rem 1rem; font-size:.88rem; }
}
@@media (max-width:380px) {
.gallery-grid { grid-template-columns:1fr; }
.hero-actions .btn-primary,
.hero-actions .btn-secondary { width:100%; text-align:center; }
}
</style>
}
<!-- ══════ HERO ══════ -->
<section id="hero">
<div class="hero-inner">
<div class="hero-text">
<span class="hero-tag">@h.GetValueOrDefault("tag","پزشک عمومی و متخصص زیبایی پوست")</span>
<h1 class="hero-name">@h.GetValueOrDefault("name_line1","دکتر") <span>@h.GetValueOrDefault("name_line2","سوسن")</span><br>@h.GetValueOrDefault("name_line3","آل‌طه")</h1>
<p class="hero-subtitle">@h.GetValueOrDefault("subtitle","با بیش از یک دهه تجربه در حوزه‌ی زیبایی و مراقبت از پوست، زیبایی واقعی شما را با علم و هنر همراه می‌کنیم.")</p>
<div class="hero-stats">
<div class="stat-item">
<span class="stat-number">@h.GetValueOrDefault("stat1_num","+۱۰")</span>
<span class="stat-label">@h.GetValueOrDefault("stat1_lbl","سال تجربه")</span>
</div>
<div class="stat-item">
<span class="stat-number">@h.GetValueOrDefault("stat2_num","+۵۰۰")</span>
<span class="stat-label">@h.GetValueOrDefault("stat2_lbl","بیمار راضی")</span>
</div>
<div class="stat-item">
<span class="stat-number">@h.GetValueOrDefault("stat3_num","+۱۵")</span>
<span class="stat-label">@h.GetValueOrDefault("stat3_lbl","خدمات تخصصی")</span>
</div>
</div>
<div class="hero-actions">
<a href="#contact" class="btn-primary">@h.GetValueOrDefault("cta_primary","رزرو نوبت")</a>
<a href="#about" class="btn-secondary">@h.GetValueOrDefault("cta_secondary","بیشتر بدانید")</a>
</div>
</div>
<div class="hero-image">
<div class="hero-image-frame">
@if (!string.IsNullOrEmpty(heroImg))
{
<img src="@heroImg" alt="@siteName" />
}
else
{
<div class="hero-image-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<p>تصویر دکتر</p>
</div>
}
</div>
@if (!badgeHidden)
{
<div class="hero-badge">
<div class="badge-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div class="badge-text">
<strong>@badgeTitle</strong>
@badgeSub
</div>
</div>
}
</div>
</div>
</section>
<!-- ══════ ABOUT ══════ -->
<section id="about">
<div class="container">
<div class="about-grid">
<div class="about-image-wrap fade-in">
<div class="about-img-box">
@if (!string.IsNullOrEmpty(aboutImg))
{
<img src="@aboutImg" alt="@siteName" />
}
else
{
<div class="about-img-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</div>
}
</div>
<div class="about-accent-box">
<span class="big">@yearsExp</span>
<span class="small">سال تجربه</span>
</div>
</div>
<div class="fade-in">
<span class="section-label">درباره من</span>
<h2 class="section-title">@a.GetValueOrDefault("title","سلامت و زیبایی پوست<br>هدف اصلی من است")</h2>
<div class="divider"></div>
<p class="section-desc">@a.GetValueOrDefault("bio","دکتر سوسن آل‌طه، پزشک عمومی و متخصص زیبایی پوست با سال‌ها تجربه در ارائه خدمات پیشرفته پوست و زیبایی.")</p>
<ul class="credential-list">
@foreach (var key in new[] { "cred1","cred2","cred3","cred4","cred5" })
{
var val = a.GetValueOrDefault(key, "");
if (!string.IsNullOrEmpty(val))
{
<li>
<div class="cred-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
<span>@val</span>
</li>
}
}
</ul>
</div>
</div>
</div>
</section>
<!-- ══════ SERVICES ══════ -->
<section id="services">
<div class="container">
<div class="services-header fade-in">
<span class="section-label">خدمات ما</span>
<h2 class="section-title">طیف گسترده‌ای از خدمات<br>زیبایی و مراقبت از پوست</h2>
<div class="divider"></div>
</div>
<div class="services-grid">
@foreach (var svc in Model.Services)
{
var hasBefore = !string.IsNullOrEmpty(svc.BeforeImageUrl);
var hasAfter = !string.IsNullOrEmpty(svc.AfterImageUrl);
<div class="service-card fade-in">
<div class="service-icon">
@if (!string.IsNullOrEmpty(svc.IconSvg))
{
@Html.Raw(svc.IconSvg)
}
else
{
@Html.Raw(IndexModel.ServiceIconSvg(svc.Title))
}
</div>
<div class="service-title">@svc.Title</div>
<p class="service-desc">@svc.Description</p>
@if (hasBefore && hasAfter)
{
<div class="svc-ba" onclick="toggleBA(this)" title="برای مشاهده قبل/بعد کلیک کنید">
<img class="ba-before" src="@svc.BeforeImageUrl" alt="قبل از درمان @svc.Title" loading="lazy"/>
<img class="ba-after" src="@svc.AfterImageUrl" alt="بعد از درمان @svc.Title" loading="lazy"/>
<div class="svc-ba-btns">
<button class="svc-ba-btn active" type="button">قبل</button>
<button class="svc-ba-btn" type="button">بعد</button>
</div>
</div>
}
else if (hasBefore)
{
<div class="svc-ba-only">
<img src="@svc.BeforeImageUrl" alt="قبل از درمان @svc.Title" loading="lazy"/>
<div class="ba-solo-label">قبل از درمان</div>
</div>
}
else if (hasAfter)
{
<div class="svc-ba-only">
<img src="@svc.AfterImageUrl" alt="بعد از درمان @svc.Title" loading="lazy"/>
<div class="ba-solo-label">بعد از درمان</div>
</div>
}
</div>
}
</div>
</div>
</section>
<!-- ══════ GALLERY ══════ -->
<section id="gallery">
<div class="container">
<div class="gallery-header fade-in">
<span class="section-label">گالری</span>
<h2 class="section-title">نتایج قبل و بعد</h2>
<div class="divider"></div>
<p class="section-desc">نمونه‌ای از نتایج فوق‌العاده درمان‌های انجام‌شده توسط دکتر آل‌طه.</p>
</div>
<div class="gallery-tabs fade-in">
<button class="tab-btn active">همه</button>
<button class="tab-btn">بوتاکس</button>
<button class="tab-btn">لیزر</button>
<button class="tab-btn">مزوتراپی</button>
<button class="tab-btn">پاکسازی</button>
</div>
<div class="gallery-grid">
@if (Model.Gallery.Any())
{
@foreach (var item in Model.Gallery)
{
<div class="gallery-item fade-in">
@if (!string.IsNullOrEmpty(item.ImageUrl))
{
<img src="@item.ImageUrl" alt="@item.Caption" loading="lazy" />
}
else
{
<div class="gallery-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p>تصویر قبل و بعد</p>
</div>
}
<div class="gallery-item-overlay"></div>
</div>
}
}
else
{
@for (int i = 0; i < 6; i++)
{
<div class="gallery-item fade-in">
<div class="gallery-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p>تصویر قبل و بعد</p>
</div>
<div class="gallery-item-overlay"></div>
</div>
}
}
</div>
</div>
</section>
<!-- ══════ TESTIMONIALS ══════ -->
<section id="testimonials">
<div class="container">
<div class="testimonials-header fade-in">
<span class="section-label">نظرات بیماران</span>
<h2 class="section-title">بیماران ما چه می‌گویند</h2>
<div class="divider"></div>
</div>
<div class="testimonials-grid">
@foreach (var t in Model.Testimonials)
{
<div class="testimonial-card fade-in">
<div class="testimonial-stars">
@for (int i = 0; i < (t.Rating > 0 ? t.Rating : 5); i++)
{
<span class="star">★</span>
}
</div>
<p class="testimonial-text">@t.Text</p>
<div class="testimonial-author">
<div class="author-avatar">@(string.IsNullOrEmpty(t.AuthorEmoji) ? "👩" : t.AuthorEmoji)</div>
<div>
<div class="author-name">@t.AuthorName</div>
<div class="author-date">@t.Date</div>
</div>
</div>
</div>
}
</div>
</div>
</section>
<!-- ══════ BLOG PREVIEW ══════ -->
@if (Model.RecentPosts.Any())
{
<section id="blog-preview">
<div class="container">
<div class="fade-in" style="margin-bottom:3.5rem;">
<span class="section-label">وبلاگ</span>
<h2 class="section-title">آخرین مقالات</h2>
<div class="divider"></div>
<p class="section-desc">دانش و تجربه در حوزه زیبایی پوست</p>
</div>
<div class="blog-preview-grid">
@foreach (var post in Model.RecentPosts)
{
<div class="post-card-prev fade-in">
<div class="post-card-prev-img">
@if (!string.IsNullOrEmpty(post.FeaturedImage))
{
<img src="@post.FeaturedImage" alt="@post.Title" loading="lazy" />
}
else { <span>📝</span> }
</div>
<div class="post-card-prev-body">
@if (post.Category != null)
{
<span class="post-cat-label">@post.Category.Name</span>
}
<a href="/blog/@post.Slug" class="post-card-prev-title">@post.Title</a>
<p class="post-card-prev-excerpt">@(post.Excerpt.Length > 120 ? post.Excerpt.Substring(0, 120) + "..." : post.Excerpt)</p>
<div class="post-card-prev-meta">
<span>🕐 @post.ReadingTimeMinutes دقیقه | 👁 @post.ViewCount</span>
<a href="/blog/@post.Slug" class="read-more-link">ادامه مطلب ←</a>
</div>
</div>
</div>
}
</div>
<div class="blog-preview-more">
<a href="/blog" class="btn-secondary">مشاهده همه مقالات</a>
</div>
</div>
</section>
}
<!-- ══════ FAQ ══════ -->
<section id="faq">
<div class="container">
<div class="fade-in" style="margin-bottom:3rem;">
<span class="section-label">سوالات متداول</span>
<h2 class="section-title">پاسخ به پرسش‌های رایج</h2>
<div class="divider"></div>
</div>
<div class="faq-list">
@foreach (var faq in Model.Faqs)
{
<details class="faq-item">
<summary>@faq.Question</summary>
<div class="faq-answer">@faq.Answer</div>
</details>
}
</div>
</div>
</section>
<!-- ══════ CONTACT ══════ -->
<section id="contact">
<div class="container">
<div class="contact-grid">
<div class="fade-in">
<span class="section-label">تماس با ما</span>
<h2 class="section-title">رزرو نوبت<br>و مشاوره</h2>
<div class="divider"></div>
<p class="section-desc">برای رزرو نوبت یا دریافت مشاوره رایگان با ما در تماس باشید.</p>
<div class="contact-info-list">
@if (!string.IsNullOrEmpty(c.GetValueOrDefault("phone","")))
{
<div class="contact-info-item">
<div class="info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13.6a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 3h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 10.6a16 16 0 0 0 6 6l.91-.91a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
</div>
<div class="info-text">
<strong>تلفن تماس</strong>
<p>@c.GetValueOrDefault("phone","")</p>
</div>
</div>
}
@if (!string.IsNullOrEmpty(c.GetValueOrDefault("email","")))
{
<div class="contact-info-item">
<div class="info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<div class="info-text">
<strong>ایمیل</strong>
<p>@c.GetValueOrDefault("email","")</p>
</div>
</div>
}
@if (!string.IsNullOrEmpty(c.GetValueOrDefault("address","")))
{
<div class="contact-info-item">
<div class="info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
</div>
<div class="info-text">
<strong>آدرس مطب</strong>
<p>@c.GetValueOrDefault("address","")</p>
</div>
</div>
}
@if (!string.IsNullOrEmpty(c.GetValueOrDefault("hours","")))
{
<div class="contact-info-item">
<div class="info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div class="info-text">
<strong>ساعات کاری</strong>
<p>@c.GetValueOrDefault("hours","")</p>
</div>
</div>
}
</div>
<div class="social-links">
@if (!string.IsNullOrEmpty(ig))
{
<a href="@ig" class="social-link" target="_blank" rel="noopener" title="اینستاگرام">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="20" rx="5" ry="5"/>
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/>
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5"/>
</svg>
</a>
}
@if (!string.IsNullOrEmpty(wa))
{
<a href="@wa" class="social-link" target="_blank" rel="noopener" title="واتساپ">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
</a>
}
@if (!string.IsNullOrEmpty(tg))
{
<a href="@tg" class="social-link" target="_blank" rel="noopener" title="تلگرام">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</a>
}
</div>
</div>
<div class="contact-form fade-in">
<div class="form-title">رزرو نوبت آنلاین</div>
<p class="form-sub">فرم زیر را پر کنید، در اسرع وقت با شما تماس می‌گیریم.</p>
<form onsubmit="handleSubmit(event)">
<div class="form-row">
<div class="form-group">
<label>نام</label>
<input type="text" placeholder="نام شما" required />
</div>
<div class="form-group">
<label>نام خانوادگی</label>
<input type="text" placeholder="نام خانوادگی" required />
</div>
</div>
<div class="form-group">
<label>شماره موبایل</label>
<input type="tel" placeholder="09XX-XXX-XXXX" required />
</div>
<div class="form-group">
<label>نوع خدمت مورد نظر</label>
<select>
<option value="" disabled selected>انتخاب خدمت</option>
@foreach (var svc in Model.Services)
{
<option>@svc.Title</option>
}
<option>سایر</option>
</select>
</div>
<div class="form-group">
<label>توضیحات (اختیاری)</label>
<textarea placeholder="مشکل پوستی یا سوالات خود را اینجا بنویسید..."></textarea>
</div>
<button type="submit" class="form-submit">ارسال و رزرو نوبت</button>
</form>
</div>
</div>
</div>
</section>
@section Scripts {
<script>
// Before/After toggle
function toggleBA(wrap) {
wrap.classList.toggle('show-after');
const btns = wrap.querySelectorAll('.svc-ba-btn');
btns[0].classList.toggle('active');
btns[1].classList.toggle('active');
}
// Tab buttons
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Form submission
function handleSubmit(e) {
e.preventDefault();
const btn = e.target.querySelector('.form-submit');
btn.textContent = '✓ درخواست شما ثبت شد';
btn.style.background = '#2D7A4F';
setTimeout(() => {
btn.textContent = 'ارسال و رزرو نوبت';
btn.style.background = '';
e.target.reset();
}, 3000);
}
// Active nav link on scroll
const sections = document.querySelectorAll('section[id]');
window.addEventListener('scroll', () => {
const scrollY = window.scrollY;
sections.forEach(section => {
const sectionTop = section.offsetTop - 100;
const id = section.getAttribute('id');
const link = document.querySelector(`header nav a[href="#${id}"]`);
if (link && scrollY > sectionTop && scrollY <= sectionTop + section.offsetHeight) {
document.querySelectorAll('header nav a').forEach(a => a.style.color = '');
link.style.color = '#B8955A';
}
});
});
</script>
}
+104
View File
@@ -0,0 +1,104 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using DrSousan.Api.Data;
using DrSousan.Api.Models;
namespace DrSousan.Api.Pages;
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
public IndexModel(AppDbContext db) => _db = db;
// Hero
public Dictionary<string, string> Hero { get; private set; } = new();
public Dictionary<string, string> About { get; private set; } = new();
public Dictionary<string, string> Contact { get; private set; } = new();
// Collections
public List<Service> Services { get; private set; } = new();
public List<GalleryItem> Gallery { get; private set; } = new();
public List<Testimonial> Testimonials { get; private set; } = new();
public List<BlogPost> RecentPosts { get; private set; } = new();
public List<Faq> Faqs { get; private set; } = new();
public async Task OnGetAsync()
{
var settings = await _db.SiteSettings.ToListAsync();
Hero = settings.Where(s => s.Section == "hero")
.ToDictionary(s => s.Key, s => s.Value);
About = settings.Where(s => s.Section == "about")
.ToDictionary(s => s.Key, s => s.Value);
Contact = settings.Where(s => s.Section == "contact")
.ToDictionary(s => s.Key, s => s.Value);
Services = await _db.Services
.Where(s => s.IsActive)
.OrderBy(s => s.Order)
.ToListAsync();
Gallery = await _db.GalleryItems
.Where(g => g.IsActive)
.OrderBy(g => g.Order)
.ToListAsync();
Testimonials = await _db.Testimonials
.Where(t => t.IsActive)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
RecentPosts = await _db.BlogPosts
.Include(p => p.Category)
.Where(p => p.IsPublished)
.OrderByDescending(p => p.PublishedAt)
.Take(3)
.ToListAsync();
Faqs = await _db.Faqs
.Where(f => f.IsActive)
.OrderBy(f => f.Order)
.ToListAsync();
// Expose site name for layout
var siteName = Hero.GetValueOrDefault("name", "دکتر سوسن آل‌طه");
ViewData["SiteName"] = siteName;
ViewData["Title"] = $"{siteName} | متخصص زیبایی پوست تهران";
}
// ── Helpers for the view ─────────────────────────────────────────────────
public static string Stars(int count)
{
var s = string.Concat(Enumerable.Repeat("⭐", count));
return string.IsNullOrEmpty(s) ? "⭐⭐⭐⭐⭐" : s;
}
public static string ServiceIcon(string title) => title switch
{
var t when t.Contains("لیزر") => "✨",
var t when t.Contains("بوتاکس") || t.Contains("فیلر") => "💉",
var t when t.Contains("مزو") => "💊",
var t when t.Contains("پاکسازی") => "🧴",
var t when t.Contains("مشاوره") => "🩺",
var t when t.Contains("هایفو") || t.Contains("HIFU") => "🔬",
var t when t.Contains("RF") => "⚡",
_ => "🌟"
};
public static string ServiceIconSvg(string title) => title switch
{
var t when t.Contains("لیزر") =>
"""<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>""",
var t when t.Contains("بوتاکس") || t.Contains("فیلر") =>
"""<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 12s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>""",
var t when t.Contains("مزو") =>
"""<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>""",
var t when t.Contains("پاکسازی") =>
"""<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>""",
var t when t.Contains("مشاوره") =>
"""<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>""",
_ =>
"""<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>"""
};
}
+193
View File
@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@RenderSection("Head", required: false)
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" onload="this.rel='stylesheet'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--gold: #B8955A;
--gold-light: #D4B483;
--gold-pale: #F5ECD8;
--bg: #FAFAF7;
--white: #FFFFFF;
--dark: #1E1E1E;
--mid: #5A5A5A;
--light: #9A9A9A;
--border: #E8E2D9;
--section-bg: #F7F4EF;
}
html { scroll-behavior: smooth; }
body { font-family: 'Vazirmatn', sans-serif; background: var(--bg); color: var(--dark); line-height: 1.8; direction: rtl; }
a { text-decoration: none; color: inherit; }
/* Navbar */
header {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
background: rgba(250,250,247,0.92); backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border); padding: 0 2rem;
height: 70px; display: flex; align-items: center; justify-content: space-between;
}
.logo { font-size: 1rem; font-weight: 600; color: var(--gold); letter-spacing: 0.03em; text-decoration: none; }
nav { display: flex; gap: 2rem; list-style: none; }
nav a { text-decoration: none; color: var(--mid); font-size: 0.88rem; font-weight: 400; transition: color 0.25s; position: relative; }
nav a::after { content: ''; position: absolute; bottom: -3px; right: 0; width: 0; height: 1px; background: var(--gold); transition: width 0.3s; }
nav a:hover { color: var(--gold); }
nav a:hover::after { width: 100%; }
.nav-cta { background: var(--gold); color: var(--white) !important; padding: 0.45rem 1.2rem; border-radius: 50px; font-size: 0.85rem !important; transition: background 0.25s, transform 0.2s !important; }
.nav-cta:hover { background: var(--gold-light) !important; transform: translateY(-1px); }
.nav-cta::after { display: none !important; }
.hamburger { display: none; flex-direction: column; gap: 5px; cursor: pointer; padding: 8px; border-radius: 8px; transition: background 0.2s; }
.hamburger:hover { background: var(--gold-pale); }
.hamburger span { display: block; width: 24px; height: 2px; background: var(--dark); border-radius: 2px; transition: transform 0.3s, opacity 0.3s; }
.hamburger.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.hamburger.open span:nth-child(2) { opacity: 0; transform: scaleX(0); }
.hamburger.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
/* Mobile nav drawer */
.mobile-nav {
display: none; position: fixed; top: 70px; right: 0; left: 0; bottom: 0;
background: rgba(0,0,0,0); z-index: 98; transition: background 0.25s;
}
.mobile-nav.open { display: block; background: rgba(0,0,0,0.35); }
.mobile-nav-drawer {
position: absolute; top: 0; right: 0; left: 0;
background: var(--white); padding: 1.2rem 2rem 2rem;
border-bottom: 1px solid var(--border);
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
display: flex; flex-direction: column; gap: 0;
transform: translateY(-8px); opacity: 0;
transition: transform 0.25s ease, opacity 0.25s ease;
}
.mobile-nav.open .mobile-nav-drawer { transform: translateY(0); opacity: 1; }
.mobile-nav-drawer a {
display: block; padding: 0.85rem 0; color: var(--dark); font-size: 0.95rem;
font-weight: 500; border-bottom: 1px solid var(--border); text-decoration: none;
transition: color 0.2s;
}
.mobile-nav-drawer a:last-child { border-bottom: none; padding-bottom: 0.5rem; }
.mobile-nav-drawer a:hover { color: var(--gold); }
.mobile-nav-drawer .nav-cta-m {
margin-top: 1rem; background: var(--gold); color: var(--white) !important;
text-align: center; padding: 0.75rem 1.5rem; border-radius: 50px;
font-weight: 600; border-bottom: none !important;
}
/* Footer */
footer { background: var(--dark); color: rgba(255,255,255,0.6); text-align: center; padding: 2.5rem 2rem; font-size: 0.85rem; }
footer a { color: var(--gold-light); text-decoration: none; }
/* Shared buttons */
.btn-primary {
background: var(--gold); color: var(--white); border: none; padding: 0.8rem 2rem;
border-radius: 50px; font-family: 'Vazirmatn', sans-serif; font-size: 0.95rem; font-weight: 500;
cursor: pointer; text-decoration: none; display: inline-block;
transition: background 0.25s, transform 0.2s, box-shadow 0.25s;
box-shadow: 0 4px 18px rgba(184,149,90,0.25);
}
.btn-primary:hover { background: var(--gold-light); transform: translateY(-2px); box-shadow: 0 8px 24px rgba(184,149,90,0.35); }
.btn-secondary {
background: transparent; color: var(--gold); border: 1.5px solid var(--gold);
padding: 0.78rem 1.8rem; border-radius: 50px; font-family: 'Vazirmatn', sans-serif;
font-size: 0.95rem; font-weight: 500; cursor: pointer; text-decoration: none;
display: inline-block; transition: all 0.25s;
}
.btn-secondary:hover { background: var(--gold); color: var(--white); transform: translateY(-2px); }
/* Shared */
.container { max-width: 1200px; margin: 0 auto; padding: 0 2rem; }
section { padding: 6rem 0; }
.section-label { font-size: 0.78rem; font-weight: 600; letter-spacing: 0.1em; color: var(--gold); text-transform: uppercase; margin-bottom: 0.8rem; display: block; }
.section-title { font-size: clamp(1.6rem, 3vw, 2.3rem); font-weight: 700; color: var(--dark); margin-bottom: 1.2rem; line-height: 1.35; }
.section-desc { font-size: 1rem; color: var(--mid); max-width: 560px; line-height: 1.9; }
.divider { width: 60px; height: 3px; background: linear-gradient(90deg, var(--gold), var(--gold-pale)); border-radius: 2px; margin: 1.2rem 0 2.5rem; }
/* Animations */
@@keyframes fadeUp { from { opacity: 0; transform: translateY(28px); } to { opacity: 1; transform: translateY(0); } }
@@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
.fade-in { opacity: 0; transform: translateY(24px); transition: opacity 0.7s ease, transform 0.7s ease; }
.fade-in.visible { opacity: 1; transform: translateY(0); }
/* Mobile nav */
@@media (max-width: 900px) {
nav { display: none; }
.hamburger { display: flex; }
}
/* Small screen tweaks */
@@media (max-width: 480px) {
header { padding: 0 1rem; }
.logo { font-size: 0.88rem; }
}
</style>
</head>
<body>
<header>
<a class="logo" href="/">@(ViewData["SiteName"] ?? "دکتر سوسن آل‌طه")</a>
<nav>
<a href="/#about">درباره من</a>
<a href="/#services">خدمات</a>
<a href="/#gallery">گالری</a>
<a href="/blog">وبلاگ</a>
<a href="/#testimonials">نظرات</a>
<a href="/#contact" class="nav-cta">رزرو نوبت</a>
</nav>
<div class="hamburger" id="hamburger" onclick="toggleMenu()" aria-label="منو" aria-expanded="false">
<span></span><span></span><span></span>
</div>
</header>
<!-- Mobile nav drawer (separate from header so it can overlay body) -->
<div class="mobile-nav" id="mobileNav" onclick="closeMobileNavOnOverlay(event)">
<div class="mobile-nav-drawer">
<a href="/#about" onclick="closeMenu()">درباره من</a>
<a href="/#services" onclick="closeMenu()">خدمات</a>
<a href="/#gallery" onclick="closeMenu()">گالری</a>
<a href="/blog" onclick="closeMenu()">وبلاگ</a>
<a href="/#testimonials" onclick="closeMenu()">نظرات</a>
<a href="/#contact" onclick="closeMenu()" class="nav-cta-m">رزرو نوبت</a>
</div>
</div>
@RenderBody()
<footer>
<p>© @DateTime.Now.Year @(ViewData["SiteName"] ?? "دکتر سوسن آل‌طه") — تمامی حقوق محفوظ است.</p>
<p style="margin-top:0.5rem; font-size:0.78rem; opacity:0.5;">طراحی شده با ❤ برای سلامت و زیبایی شما</p>
</footer>
<script>
const _ham = document.getElementById('hamburger');
const _nav = document.getElementById('mobileNav');
function toggleMenu() {
const open = _nav.classList.toggle('open');
_ham.classList.toggle('open', open);
_ham.setAttribute('aria-expanded', String(open));
document.body.style.overflow = open ? 'hidden' : '';
}
function closeMenu() {
_nav.classList.remove('open');
_ham.classList.remove('open');
_ham.setAttribute('aria-expanded', 'false');
document.body.style.overflow = '';
}
function closeMobileNavOnOverlay(e) {
// close only when clicking the dim overlay, not the drawer itself
if (e.target === _nav) closeMenu();
}
// close on Escape key
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });
// close when viewport widens past mobile breakpoint
window.addEventListener('resize', () => { if (window.innerWidth > 900) closeMenu(); });
// Scroll fade-in
const observer = new IntersectionObserver((entries) => {
entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible'); });
}, { threshold: 0.1 });
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
});
</script>
@RenderSection("Scripts", required: false)
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
@using DrSousan.Api.Models
@using DrSousan.Api.Data
@namespace DrSousan.Api.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+3
View File
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:18342",
"sslPort": 44378
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "weatherforecast",
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "weatherforecast",
"applicationUrl": "https://localhost:7001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"ConnectionStrings": {
"Default": "Data Source=drsousan.db"
},
"Jwt": {
"Key": "DrSousanSecretKey2024!ChangeThisInProduction!MinLength32Chars",
"Issuer": "DrSousanApi",
"Audience": "DrSousanAdmin"
},
"Admin": {
"Username": "admin",
"Password": "admin123"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
/* site.css — minimal shared overrides only; all page CSS is inline in each Razor page */
/* The _Layout.cshtml already defines all base/reset/nav/footer styles inline */
+915
View File
@@ -0,0 +1,915 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>پنل مدیریت | دکتر سوسن آل‌طه</title>
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" onload="this.rel='stylesheet'"/>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--gold:#B8955A;--gold-l:#D4B483;--gold-pale:#F5ECD8;
--bg:#F4F6F8;--white:#fff;--dark:#1E1E1E;--mid:#5A5A5A;
--light:#9A9A9A;--border:#E2E8F0;--danger:#E53935;--success:#2E7D32;
--sidebar:240px;
}
body{font-family:'Vazirmatn',Tahoma,sans-serif;background:var(--bg);color:var(--dark);direction:rtl;display:flex;min-height:100vh}
/* ── Sidebar ── */
.sidebar{width:var(--sidebar);background:var(--dark);color:#fff;display:flex;flex-direction:column;flex-shrink:0;position:fixed;top:0;right:0;bottom:0;z-index:50}
.sidebar-logo{padding:1.5rem 1.2rem;border-bottom:1px solid rgba(255,255,255,.08);font-size:.9rem;font-weight:600;color:var(--gold-l);line-height:1.4}
.sidebar-logo span{display:block;font-size:.7rem;color:rgba(255,255,255,.4);font-weight:400;margin-top:.2rem}
nav.sidebar-nav{flex:1;overflow-y:auto;padding:.8rem 0}
.nav-section{font-size:.65rem;font-weight:600;letter-spacing:.08em;color:rgba(255,255,255,.3);padding:.8rem 1.2rem .3rem;text-transform:uppercase}
.nav-item{display:flex;align-items:center;gap:.7rem;padding:.65rem 1.2rem;color:rgba(255,255,255,.65);cursor:pointer;font-size:.85rem;transition:background .2s,color .2s;border-right:3px solid transparent}
.nav-item:hover{background:rgba(255,255,255,.05);color:#fff}
.nav-item.active{background:rgba(184,149,90,.12);color:var(--gold-l);border-right-color:var(--gold)}
.nav-item svg{width:16px;height:16px;flex-shrink:0;opacity:.7}
.nav-item.active svg{opacity:1}
.sidebar-footer{padding:1rem 1.2rem;border-top:1px solid rgba(255,255,255,.08);font-size:.75rem;color:rgba(255,255,255,.35)}
/* ── Main ── */
.main{margin-right:var(--sidebar);flex:1;display:flex;flex-direction:column;min-height:100vh}
.topbar{background:var(--white);border-bottom:1px solid var(--border);padding:.75rem 2rem;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:40}
.topbar-title{font-size:1rem;font-weight:600;color:var(--dark)}
.topbar-actions{display:flex;gap:.7rem;align-items:center}
.page-content{padding:2rem;flex:1}
/* ── Cards ── */
.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1.2rem;margin-bottom:2rem}
.stat-card{background:var(--white);border-radius:14px;padding:1.4rem 1.6rem;border:1px solid var(--border);display:flex;flex-direction:column;gap:.4rem}
.stat-card .label{font-size:.78rem;color:var(--light)}
.stat-card .value{font-size:2rem;font-weight:700;color:var(--dark)}
.stat-card .icon{width:38px;height:38px;border-radius:10px;background:var(--gold-pale);display:flex;align-items:center;justify-content:center;margin-bottom:.4rem}
.stat-card .icon svg{width:18px;height:18px;color:var(--gold)}
.card{background:var(--white);border-radius:14px;border:1px solid var(--border);overflow:hidden;margin-bottom:1.5rem}
.card-header{padding:1.2rem 1.5rem;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.card-title{font-size:.95rem;font-weight:600}
.card-body{padding:1.5rem}
/* ── Buttons ── */
.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.55rem 1.1rem;border-radius:8px;font-family:inherit;font-size:.85rem;font-weight:500;cursor:pointer;border:1.5px solid transparent;transition:all .2s}
.btn-primary{background:var(--gold);color:#fff;border-color:var(--gold)}
.btn-primary:hover{background:var(--gold-l)}
.btn-secondary{background:transparent;color:var(--mid);border-color:var(--border)}
.btn-secondary:hover{border-color:var(--gold);color:var(--gold)}
.btn-danger{background:transparent;color:var(--danger);border-color:var(--danger)}
.btn-danger:hover{background:var(--danger);color:#fff}
.btn-sm{padding:.35rem .7rem;font-size:.78rem}
.btn svg{width:15px;height:15px}
/* ── Table ── */
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:.85rem}
th{background:#F8FAFC;padding:.75rem 1rem;text-align:right;font-weight:600;color:var(--mid);font-size:.78rem;border-bottom:1px solid var(--border)}
td{padding:.75rem 1rem;border-bottom:1px solid var(--border);color:var(--dark);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:#FAFBFC}
.badge{display:inline-flex;align-items:center;padding:.2rem .65rem;border-radius:50px;font-size:.72rem;font-weight:600}
.badge-green{background:#E8F5E9;color:#2E7D32}
.badge-red{background:#FFEBEE;color:#C62828}
.badge-gold{background:var(--gold-pale);color:var(--gold)}
/* ── Forms ── */
.form-grid{display:grid;gap:1rem}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.form-group{display:flex;flex-direction:column;gap:.35rem}
.form-group label{font-size:.82rem;font-weight:500;color:var(--dark)}
.form-group input,.form-group select,.form-group textarea{
border:1.5px solid var(--border);border-radius:8px;padding:.65rem .9rem;
font-family:inherit;font-size:.88rem;color:var(--dark);direction:rtl;
transition:border-color .2s,box-shadow .2s;outline:none;background:var(--white)
}
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{border-color:var(--gold);box-shadow:0 0 0 3px rgba(184,149,90,.1)}
.form-group textarea{resize:vertical;min-height:100px}
.form-hint{font-size:.72rem;color:var(--light);margin-top:.2rem}
.seo-score{display:flex;align-items:center;gap:.5rem;padding:.6rem 1rem;border-radius:8px;font-size:.8rem}
.seo-score.good{background:#E8F5E9;color:#2E7D32}
.seo-score.ok{background:#FFF3E0;color:#E65100}
.seo-score.bad{background:#FFEBEE;color:#C62828}
/* ── Modal ── */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:200;display:flex;align-items:center;justify-content:center;padding:1rem}
.modal-overlay.hidden{display:none}
.modal{background:var(--white);border-radius:16px;width:100%;max-width:720px;max-height:90vh;overflow-y:auto}
.modal-header{padding:1.2rem 1.5rem;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;background:var(--white);z-index:1}
.modal-title{font-size:1rem;font-weight:600}
.modal-close{background:none;border:none;cursor:pointer;padding:.4rem;color:var(--light)}
.modal-body{padding:1.5rem}
.modal-footer{padding:1rem 1.5rem;border-top:1px solid var(--border);display:flex;gap:.7rem;justify-content:flex-start}
/* ── Toast ── */
.toast-container{position:fixed;bottom:1.5rem;left:1.5rem;z-index:300;display:flex;flex-direction:column;gap:.5rem}
.toast{background:var(--dark);color:#fff;padding:.7rem 1.2rem;border-radius:10px;font-size:.85rem;animation:slideIn .3s ease;box-shadow:0 4px 20px rgba(0,0,0,.2)}
.toast.success{border-left:4px solid #4CAF50}
.toast.error{border-left:4px solid var(--danger)}
@keyframes slideIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
/* ── Rich text editor minimal ── */
.editor-toolbar{border:1.5px solid var(--border);border-bottom:none;border-radius:8px 8px 0 0;padding:.4rem .6rem;display:flex;gap:.3rem;flex-wrap:wrap;background:#F8FAFC}
.editor-toolbar button{background:none;border:none;padding:.3rem .5rem;border-radius:4px;cursor:pointer;font-size:.82rem;font-family:inherit;color:var(--mid)}
.editor-toolbar button:hover{background:var(--gold-pale);color:var(--gold)}
.editor-content{border:1.5px solid var(--border);border-radius:0 0 8px 8px;padding:.8rem 1rem;min-height:250px;outline:none;font-size:.9rem;line-height:1.8;direction:rtl}
/* ── Pages ── */
.page{display:none}
.page.active{display:block}
/* ── Login ── */
.login-screen{position:fixed;inset:0;background:var(--dark);display:flex;align-items:center;justify-content:center;z-index:1000}
.login-box{background:var(--white);border-radius:20px;padding:2.5rem;width:380px;text-align:center}
.login-logo{font-size:1.1rem;font-weight:700;color:var(--gold);margin-bottom:.3rem}
.login-sub{font-size:.8rem;color:var(--light);margin-bottom:2rem}
.login-screen.hidden{display:none}
</style>
</head>
<body>
<!-- ══ LOGIN ══ -->
<div class="login-screen" id="loginScreen">
<div class="login-box">
<div class="login-logo">دکتر سوسن آل‌طه</div>
<div class="login-sub">پنل مدیریت محتوا</div>
<div class="form-group" style="margin-bottom:1rem">
<label>نام کاربری</label>
<input type="text" id="loginUser" value="admin" placeholder="نام کاربری"/>
</div>
<div class="form-group" style="margin-bottom:1.5rem">
<label>رمز عبور</label>
<input type="password" id="loginPass" value="admin123" placeholder="رمز عبور"/>
</div>
<button class="btn btn-primary" style="width:100%;justify-content:center" onclick="doLogin()">ورود به پنل</button>
<p id="loginErr" style="color:var(--danger);font-size:.8rem;margin-top:.8rem;display:none">نام کاربری یا رمز عبور اشتباه است</p>
</div>
</div>
<!-- ══ SIDEBAR ══ -->
<aside class="sidebar">
<div class="sidebar-logo">مدیریت سایت<span>دکتر سوسن آل‌طه</span></div>
<nav class="sidebar-nav">
<div class="nav-section">داشبورد</div>
<div class="nav-item active" onclick="showPage('dashboard',this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
داشبورد
</div>
<div class="nav-section">محتوای سایت</div>
<div class="nav-item" onclick="showPage('hero',this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
صفحه اصلی (هرو)
</div>
<div class="nav-item" onclick="showPage('about',this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
درباره من
</div>
<div class="nav-item" onclick="showPage('contact',this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13.6a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 3h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L7.91 10.6a16 16 0 0 0 6 6l.91-.91a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
اطلاعات تماس
</div>
<div class="nav-section">بخش‌های سایت</div>
<div class="nav-item" onclick="showPage('services',this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
خدمات
</div>
<div class="nav-item" onclick="showPage('gallery',this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
گالری
</div>
<div class="nav-item" onclick="showPage('testimonials',this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
نظرات بیماران
</div>
<div class="nav-section">وبلاگ و SEO</div>
<div class="nav-item" onclick="showPage('blogposts',this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
مقالات
</div>
<div class="nav-item" onclick="showPage('categories',this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
دسته‌بندی‌ها
</div>
<div class="nav-item" onclick="showPage('seo',this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
گزارش SEO
</div>
</nav>
<div class="sidebar-footer">API: <span id="apiLabel">localhost:5000</span></div>
</aside>
<!-- ══ MAIN ══ -->
<div class="main">
<div class="topbar">
<div class="topbar-title" id="pageTitle">داشبورد</div>
<div class="topbar-actions">
<a href="../index.html" target="_blank" class="btn btn-secondary btn-sm">مشاهده سایت</a>
<button class="btn btn-danger btn-sm" onclick="logout()">خروج</button>
</div>
</div>
<div class="page-content">
<!-- ── DASHBOARD ── -->
<div class="page active" id="page-dashboard">
<div class="stat-grid" id="dashStats">
<div class="stat-card"><div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div><div class="label">مقالات منتشرشده</div><div class="value" id="ds-posts">-</div></div>
<div class="stat-card"><div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></div><div class="label">کل بازدید مقالات</div><div class="value" id="ds-views">-</div></div>
<div class="stat-card"><div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg></div><div class="label">نظرات بیماران</div><div class="value" id="ds-testimonials">-</div></div>
<div class="stat-card"><div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg></div><div class="label">مقالات بدون متا</div><div class="value" id="ds-nometa" style="color:var(--danger)">-</div></div>
</div>
<div class="card">
<div class="card-header"><div class="card-title">پربازدیدترین مقالات</div></div>
<div class="table-wrap"><table><thead><tr><th>عنوان</th><th>بازدید</th><th>عملیات</th></tr></thead><tbody id="topPostsTable"></tbody></table></div>
</div>
</div>
<!-- ── HERO ── -->
<div class="page" id="page-hero">
<div class="card">
<div class="card-header"><div class="card-title">ویرایش بخش هرو</div></div>
<div class="card-body">
<div class="form-grid">
<div class="form-row">
<div class="form-group"><label>نام دکتر</label><input id="hero-name"/></div>
<div class="form-group"><label>تخصص (زیر نام)</label><input id="hero-title"/></div>
</div>
<div class="form-group"><label>توضیح کوتاه</label><textarea id="hero-subtitle" rows="2"></textarea></div>
<div class="form-row">
<div class="form-group"><label>آمار ۱ - عدد</label><input id="hero-stat1_num"/></div>
<div class="form-group"><label>آمار ۱ - برچسب</label><input id="hero-stat1_lbl"/></div>
</div>
<div class="form-row">
<div class="form-group"><label>آمار ۲ - عدد</label><input id="hero-stat2_num"/></div>
<div class="form-group"><label>آمار ۲ - برچسب</label><input id="hero-stat2_lbl"/></div>
</div>
<div class="form-row">
<div class="form-group"><label>آمار ۳ - عدد</label><input id="hero-stat3_num"/></div>
<div class="form-group"><label>آمار ۳ - برچسب</label><input id="hero-stat3_lbl"/></div>
</div>
<div class="form-row">
<div class="form-group"><label>دکمه اصلی</label><input id="hero-cta_primary"/></div>
<div class="form-group"><label>دکمه ثانویه</label><input id="hero-cta_secondary"/></div>
</div>
</div>
<div style="margin-top:1.5rem"><button class="btn btn-primary" onclick="saveSection('hero')">ذخیره تغییرات</button></div>
</div>
</div>
</div>
<!-- ── ABOUT ── -->
<div class="page" id="page-about">
<div class="card">
<div class="card-header"><div class="card-title">ویرایش بخش درباره من</div></div>
<div class="card-body">
<div class="form-grid">
<div class="form-row">
<div class="form-group"><label>عنوان</label><input id="about-title"/></div>
<div class="form-group"><label>سال‌های تجربه</label><input id="about-years_exp"/></div>
</div>
<div class="form-group"><label>بیوگرافی</label><textarea id="about-bio" rows="4"></textarea></div>
<div class="form-group"><label>دستاورد ۱</label><input id="about-cred1"/></div>
<div class="form-group"><label>دستاورد ۲</label><input id="about-cred2"/></div>
<div class="form-group"><label>دستاورد ۳</label><input id="about-cred3"/></div>
<div class="form-group"><label>دستاورد ۴</label><input id="about-cred4"/></div>
<div class="form-group"><label>دستاورد ۵</label><input id="about-cred5"/></div>
</div>
<div style="margin-top:1.5rem"><button class="btn btn-primary" onclick="saveSection('about')">ذخیره تغییرات</button></div>
</div>
</div>
</div>
<!-- ── CONTACT ── -->
<div class="page" id="page-contact">
<div class="card">
<div class="card-header"><div class="card-title">اطلاعات تماس</div></div>
<div class="card-body">
<div class="form-grid">
<div class="form-row">
<div class="form-group"><label>تلفن</label><input id="contact-phone"/></div>
<div class="form-group"><label>ایمیل</label><input id="contact-email"/></div>
</div>
<div class="form-group"><label>آدرس</label><input id="contact-address"/></div>
<div class="form-group"><label>ساعات کاری</label><input id="contact-hours"/></div>
<div class="form-row">
<div class="form-group"><label>لینک اینستاگرام</label><input id="contact-instagram" dir="ltr"/></div>
<div class="form-group"><label>لینک واتساپ</label><input id="contact-whatsapp" dir="ltr"/></div>
</div>
<div class="form-group"><label>لینک تلگرام</label><input id="contact-telegram" dir="ltr"/></div>
</div>
<div style="margin-top:1.5rem"><button class="btn btn-primary" onclick="saveSection('contact')">ذخیره تغییرات</button></div>
</div>
</div>
</div>
<!-- ── SERVICES ── -->
<div class="page" id="page-services">
<div class="card">
<div class="card-header">
<div class="card-title">خدمات</div>
<button class="btn btn-primary btn-sm" onclick="openSvcModal()">+ افزودن خدمت</button>
</div>
<div class="table-wrap">
<table><thead><tr><th>#</th><th>عنوان</th><th>توضیح</th><th>وضعیت</th><th>عملیات</th></tr></thead>
<tbody id="servicesTable"></tbody></table>
</div>
</div>
</div>
<!-- ── GALLERY ── -->
<div class="page" id="page-gallery">
<div class="card">
<div class="card-header">
<div class="card-title">گالری</div>
<button class="btn btn-primary btn-sm" onclick="openGalleryModal()">+ افزودن تصویر</button>
</div>
<div class="table-wrap">
<table><thead><tr><th>دسته</th><th>توضیح</th><th>وضعیت</th><th>عملیات</th></tr></thead>
<tbody id="galleryTable"></tbody></table>
</div>
</div>
</div>
<!-- ── TESTIMONIALS ── -->
<div class="page" id="page-testimonials">
<div class="card">
<div class="card-header">
<div class="card-title">نظرات بیماران</div>
<button class="btn btn-primary btn-sm" onclick="openTestimModal()">+ افزودن نظر</button>
</div>
<div class="table-wrap">
<table><thead><tr><th>نام</th><th>متن</th><th>امتیاز</th><th>وضعیت</th><th>عملیات</th></tr></thead>
<tbody id="testimonialsTable"></tbody></table>
</div>
</div>
</div>
<!-- ── BLOG POSTS ── -->
<div class="page" id="page-blogposts">
<div class="card">
<div class="card-header">
<div class="card-title">مقالات وبلاگ</div>
<button class="btn btn-primary btn-sm" onclick="openPostEditor()">+ مقاله جدید</button>
</div>
<div class="table-wrap">
<table><thead><tr><th>عنوان</th><th>دسته</th><th>کلیدواژه</th><th>بازدید</th><th>وضعیت</th><th>عملیات</th></tr></thead>
<tbody id="postsTable"></tbody></table>
</div>
</div>
</div>
<!-- ── CATEGORIES ── -->
<div class="page" id="page-categories">
<div class="card">
<div class="card-header">
<div class="card-title">دسته‌بندی‌ها</div>
<button class="btn btn-primary btn-sm" onclick="openCatModal()">+ دسته جدید</button>
</div>
<div class="table-wrap">
<table><thead><tr><th>نام</th><th>اسلاگ</th><th>مقالات</th><th>عملیات</th></tr></thead>
<tbody id="categoriesTable"></tbody></table>
</div>
</div>
</div>
<!-- ── SEO ── -->
<div class="page" id="page-seo">
<div class="stat-grid" style="grid-template-columns:repeat(3,1fr)">
<div class="stat-card"><div class="label">مقالات منتشرشده</div><div class="value" id="seo-posts">-</div></div>
<div class="stat-card"><div class="label">کل بازدید</div><div class="value" id="seo-views">-</div></div>
<div class="stat-card"><div class="label">مقالات بدون متا</div><div class="value" id="seo-nometa" style="color:var(--danger)">-</div></div>
</div>
<div class="card">
<div class="card-header"><div class="card-title">پربازدیدترین مقالات</div></div>
<div class="table-wrap"><table><thead><tr><th>عنوان</th><th>بازدید</th></tr></thead><tbody id="seoTopPosts"></tbody></table></div>
</div>
<div class="card">
<div class="card-header"><div class="card-title">راهنمای بهبود SEO</div></div>
<div class="card-body" style="font-size:.88rem;line-height:2;color:var(--mid)">
<p>✅ هر مقاله باید <strong>MetaTitle</strong> زیر ۷۰ کاراکتر داشته باشد</p>
<p><strong>MetaDescription</strong> بین ۱۵۰-۱۶۰ کاراکتر</p>
<p><strong>کلیدواژه اصلی</strong> در عنوان، اول پاراگراف و URL وجود داشته باشد</p>
<p>✅ هر مقاله حداقل <strong>۵۰۰ کلمه</strong> داشته باشد</p>
<p>✅ از <strong>H2 و H3</strong> در ساختار مقاله استفاده کنید</p>
<p>✅ لینک‌دهی داخلی بین مقالات انجام دهید</p>
<p>✅ تصاویر دارای <strong>alt text</strong> فارسی باشند</p>
<p>🔗 <a href="../sitemap.xml" target="_blank" style="color:var(--gold)">مشاهده Sitemap</a> | <a href="../robots.txt" target="_blank" style="color:var(--gold)">مشاهده Robots.txt</a></p>
</div>
</div>
</div>
</div><!-- /page-content -->
</div><!-- /main -->
<!-- ══ MODALS ══ -->
<!-- Service Modal -->
<div class="modal-overlay hidden" id="svcModal">
<div class="modal">
<div class="modal-header"><div class="modal-title" id="svcModalTitle">افزودن خدمت</div><button class="modal-close" onclick="closeModal('svcModal')"></button></div>
<div class="modal-body">
<input type="hidden" id="svc-id"/>
<div class="form-grid">
<div class="form-group"><label>عنوان</label><input id="svc-title"/></div>
<div class="form-group"><label>توضیح</label><textarea id="svc-desc" rows="3"></textarea></div>
<div class="form-row">
<div class="form-group"><label>ترتیب</label><input type="number" id="svc-order" value="1"/></div>
<div class="form-group"><label>وضعیت</label><select id="svc-active"><option value="true">فعال</option><option value="false">غیرفعال</option></select></div>
</div>
</div>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="saveSvc()">ذخیره</button><button class="btn btn-secondary" onclick="closeModal('svcModal')">انصراف</button></div>
</div>
</div>
<!-- Gallery Modal -->
<div class="modal-overlay hidden" id="galleryModal">
<div class="modal">
<div class="modal-header"><div class="modal-title" id="galleryModalTitle">افزودن تصویر</div><button class="modal-close" onclick="closeModal('galleryModal')"></button></div>
<div class="modal-body">
<input type="hidden" id="gal-id"/>
<div class="form-grid">
<div class="form-group"><label>آدرس تصویر اصلی</label><input id="gal-img" dir="ltr" placeholder="https://..."/></div>
<div class="form-row">
<div class="form-group"><label>آدرس تصویر قبل</label><input id="gal-before" dir="ltr" placeholder="https://..."/></div>
<div class="form-group"><label>آدرس تصویر بعد</label><input id="gal-after" dir="ltr" placeholder="https://..."/></div>
</div>
<div class="form-row">
<div class="form-group"><label>دسته‌بندی</label><input id="gal-cat" placeholder="بوتاکس، لیزر..."/></div>
<div class="form-group"><label>ترتیب</label><input type="number" id="gal-order" value="1"/></div>
</div>
<div class="form-group"><label>توضیح</label><input id="gal-caption"/></div>
<div class="form-group"><label>وضعیت</label><select id="gal-active"><option value="true">فعال</option><option value="false">غیرفعال</option></select></div>
</div>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="saveGallery()">ذخیره</button><button class="btn btn-secondary" onclick="closeModal('galleryModal')">انصراف</button></div>
</div>
</div>
<!-- Testimonial Modal -->
<div class="modal-overlay hidden" id="testimModal">
<div class="modal">
<div class="modal-header"><div class="modal-title">افزودن / ویرایش نظر</div><button class="modal-close" onclick="closeModal('testimModal')"></button></div>
<div class="modal-body">
<input type="hidden" id="testim-id"/>
<div class="form-grid">
<div class="form-row">
<div class="form-group"><label>نام</label><input id="testim-name"/></div>
<div class="form-group"><label>ایموجی</label><input id="testim-emoji" value="👩"/></div>
</div>
<div class="form-group"><label>متن نظر</label><textarea id="testim-text" rows="3"></textarea></div>
<div class="form-row">
<div class="form-group"><label>امتیاز (۱-۵)</label><input type="number" id="testim-rating" value="5" min="1" max="5"/></div>
<div class="form-group"><label>تاریخ</label><input id="testim-date" placeholder="اردیبهشت ۱۴۰۳"/></div>
</div>
<div class="form-group"><label>وضعیت</label><select id="testim-active"><option value="true">فعال</option><option value="false">غیرفعال</option></select></div>
</div>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="saveTestim()">ذخیره</button><button class="btn btn-secondary" onclick="closeModal('testimModal')">انصراف</button></div>
</div>
</div>
<!-- Category Modal -->
<div class="modal-overlay hidden" id="catModal">
<div class="modal" style="max-width:480px">
<div class="modal-header"><div class="modal-title" id="catModalTitle">دسته جدید</div><button class="modal-close" onclick="closeModal('catModal')"></button></div>
<div class="modal-body">
<input type="hidden" id="cat-id"/>
<div class="form-grid">
<div class="form-group"><label>نام</label><input id="cat-name"/></div>
<div class="form-group"><label>اسلاگ (خودکار)</label><input id="cat-slug" dir="ltr" placeholder="auto-generated"/></div>
<div class="form-group"><label>توضیح</label><textarea id="cat-desc" rows="2"></textarea></div>
</div>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="saveCat()">ذخیره</button><button class="btn btn-secondary" onclick="closeModal('catModal')">انصراف</button></div>
</div>
</div>
<!-- Blog Post Editor Modal -->
<div class="modal-overlay hidden" id="postModal">
<div class="modal" style="max-width:900px">
<div class="modal-header"><div class="modal-title" id="postModalTitle">مقاله جدید</div><button class="modal-close" onclick="closeModal('postModal')"></button></div>
<div class="modal-body">
<input type="hidden" id="post-id"/>
<div class="form-grid">
<div class="form-group"><label>عنوان مقاله</label><input id="post-title" oninput="autoSlug()"/></div>
<div class="form-row">
<div class="form-group"><label>اسلاگ (URL)</label><input id="post-slug" dir="ltr"/></div>
<div class="form-group"><label>دسته‌بندی</label><select id="post-category"><option value="">انتخاب دسته...</option></select></div>
</div>
<div class="form-group"><label>خلاصه (Excerpt)</label><textarea id="post-excerpt" rows="2"></textarea></div>
<div class="form-group">
<label>محتوای مقاله</label>
<div class="editor-toolbar">
<button onclick="fmt('bold')"><b>B</b></button>
<button onclick="fmt('italic')"><i>I</i></button>
<button onclick="fmtBlock('h2')">H2</button>
<button onclick="fmtBlock('h3')">H3</button>
<button onclick="fmtBlock('p')">P</button>
<button onclick="fmt('insertUnorderedList')">• لیست</button>
<button onclick="fmt('insertOrderedList')">۱. لیست</button>
<button onclick="insLink()">🔗 لینک</button>
</div>
<div class="editor-content" id="post-content" contenteditable="true" dir="rtl"></div>
</div>
<div style="border-top:1px solid var(--border);padding-top:1rem;margin-top:.5rem">
<p style="font-size:.82rem;font-weight:600;color:var(--gold);margin-bottom:1rem">تنظیمات SEO</p>
<div class="form-group"><label>کلیدواژه اصلی (Focus Keyword)</label><input id="post-focus" oninput="checkSeo()"/><div class="form-hint">کلیدواژه‌ای که می‌خواهید برای آن رتبه بگیرید</div></div>
<div id="seoFeedback" style="margin:.5rem 0"></div>
<div class="form-group"><label>Meta Title <span style="font-weight:400;color:var(--light)" id="mtLen">(0/70)</span></label><input id="post-metatitle" oninput="updateLen('post-metatitle','mtLen',70)"/></div>
<div class="form-group"><label>Meta Description <span style="font-weight:400;color:var(--light)" id="mdLen">(0/160)</span></label><textarea id="post-metadesc" rows="2" oninput="updateLen('post-metadesc','mdLen',160)"></textarea></div>
<div class="form-group"><label>کلیدواژه‌ها (با کاما جدا کنید)</label><input id="post-keywords" placeholder="بوتاکس, جوانسازی, پوست..."/></div>
<div class="form-group"><label>تصویر شاخص</label><input id="post-image" dir="ltr" placeholder="https://..."/></div>
<div class="form-row">
<div class="form-group"><label>نوع Schema</label><select id="post-schematype"><option value="MedicalWebPage">MedicalWebPage</option><option value="Article">Article</option><option value="FAQPage">FAQPage</option></select></div>
<div class="form-group"><label>وضعیت انتشار</label><select id="post-published"><option value="false">پیش‌نویس</option><option value="true">منتشر</option></select></div>
</div>
</div>
</div>
</div>
<div class="modal-footer"><button class="btn btn-primary" onclick="savePost()">ذخیره مقاله</button><button class="btn btn-secondary" onclick="closeModal('postModal')">انصراف</button></div>
</div>
</div>
<!-- Toast container -->
<div class="toast-container" id="toastContainer"></div>
<script>
const API = 'http://localhost:5000';
let token = localStorage.getItem('dr_token') || '';
// ── Auth ──────────────────────────────────────────────────────────────────────
async function doLogin() {
const u = document.getElementById('loginUser').value;
const p = document.getElementById('loginPass').value;
const r = await fetch(`${API}/api/auth/login`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({username:u, password:p})
});
if (!r.ok) { document.getElementById('loginErr').style.display='block'; return; }
const d = await r.json();
token = d.token;
localStorage.setItem('dr_token', token);
document.getElementById('loginScreen').classList.add('hidden');
init();
}
function logout() {
localStorage.removeItem('dr_token');
location.reload();
}
// ── API helper ────────────────────────────────────────────────────────────────
async function api(path, opts={}) {
const h = {'Content-Type':'application/json'};
if (token) h['Authorization'] = `Bearer ${token}`;
const r = await fetch(`${API}${path}`, {...opts, headers:{...h, ...(opts.headers||{})}});
if (r.status === 401) { logout(); return null; }
if (r.status === 204) return null;
if (!r.ok) { toast('خطا در سرور: ' + r.status, 'error'); return null; }
return r.json().catch(()=>null);
}
function toast(msg, type='success') {
const c = document.getElementById('toastContainer');
const el = document.createElement('div');
el.className = `toast ${type}`;
el.textContent = msg;
c.appendChild(el);
setTimeout(()=>el.remove(), 3500);
}
// ── Navigation ────────────────────────────────────────────────────────────────
const pageTitles = {dashboard:'داشبورد',hero:'صفحه اصلی',about:'درباره من',contact:'تماس',services:'خدمات',gallery:'گالری',testimonials:'نظرات',blogposts:'مقالات',categories:'دسته‌بندی‌ها',seo:'گزارش SEO'};
function showPage(name, el) {
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
document.getElementById('page-'+name).classList.add('active');
el.classList.add('active');
document.getElementById('pageTitle').textContent = pageTitles[name] || name;
loadPage(name);
}
function loadPage(name) {
if (name==='dashboard') loadDashboard();
else if (name==='hero') loadSection('hero');
else if (name==='about') loadSection('about');
else if (name==='contact') loadSection('contact');
else if (name==='services') loadServices();
else if (name==='gallery') loadGallery();
else if (name==='testimonials') loadTestimonials();
else if (name==='blogposts') loadPosts();
else if (name==='categories') loadCategories();
else if (name==='seo') loadSeo();
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
async function loadDashboard() {
const [seo, testims] = await Promise.all([
api('/api/seo/stats'),
api('/api/testimonials/all')
]);
if (seo) {
document.getElementById('ds-posts').textContent = seo.total;
document.getElementById('ds-views').textContent = seo.views;
document.getElementById('ds-nometa').textContent = seo.noMeta;
const tb = document.getElementById('topPostsTable');
tb.innerHTML = seo.topPosts.map(p=>`<tr><td>${p.title}</td><td><span class="badge badge-gold">${p.viewCount}</span></td><td><a href="../post.html?slug=${p.slug}" target="_blank" class="btn btn-secondary btn-sm">مشاهده</a></td></tr>`).join('');
}
if (testims) document.getElementById('ds-testimonials').textContent = testims.length;
}
// ── Section settings ──────────────────────────────────────────────────────────
async function loadSection(sec) {
const data = await api(`/api/settings/${sec}`);
if (!data) return;
Object.entries(data).forEach(([k,v])=>{
const el = document.getElementById(`${sec}-${k}`);
if (el) el.value = v;
});
}
async function saveSection(sec) {
const inputs = document.querySelectorAll(`[id^="${sec}-"]`);
const settings = {};
inputs.forEach(el=>{
const key = el.id.replace(`${sec}-`,'');
settings[key] = el.value;
});
await api(`/api/settings/${sec}`, {method:'PUT', body:JSON.stringify({settings})});
toast('تغییرات با موفقیت ذخیره شد ✓');
}
// ── Services ──────────────────────────────────────────────────────────────────
let svcs=[];
async function loadServices() {
svcs = await api('/api/services/all') || [];
document.getElementById('servicesTable').innerHTML = svcs.map(s=>`
<tr>
<td>${s.order}</td>
<td><strong>${s.title}</strong></td>
<td style="max-width:300px;color:var(--mid)">${s.description.substring(0,60)}...</td>
<td><span class="badge ${s.isActive?'badge-green':'badge-red'}">${s.isActive?'فعال':'غیرفعال'}</span></td>
<td><button class="btn btn-secondary btn-sm" onclick="editSvc(${s.id})">ویرایش</button> <button class="btn btn-danger btn-sm" onclick="deleteSvc(${s.id})">حذف</button></td>
</tr>`).join('');
}
function openSvcModal(id) {
document.getElementById('svcModalTitle').textContent = 'افزودن خدمت';
document.getElementById('svc-id').value='';
document.getElementById('svc-title').value='';
document.getElementById('svc-desc').value='';
document.getElementById('svc-order').value=svcs.length+1;
document.getElementById('svc-active').value='true';
document.getElementById('svcModal').classList.remove('hidden');
}
function editSvc(id) {
const s = svcs.find(x=>x.id===id);
document.getElementById('svcModalTitle').textContent = 'ویرایش خدمت';
document.getElementById('svc-id').value=s.id;
document.getElementById('svc-title').value=s.title;
document.getElementById('svc-desc').value=s.description;
document.getElementById('svc-order').value=s.order;
document.getElementById('svc-active').value=String(s.isActive);
document.getElementById('svcModal').classList.remove('hidden');
}
async function saveSvc() {
const id = document.getElementById('svc-id').value;
const body = {
title: document.getElementById('svc-title').value,
description: document.getElementById('svc-desc').value,
order: parseInt(document.getElementById('svc-order').value),
isActive: document.getElementById('svc-active').value==='true',
iconSvg:''
};
if (id) await api(`/api/services/${id}`,{method:'PUT',body:JSON.stringify(body)});
else await api('/api/services',{method:'POST',body:JSON.stringify(body)});
closeModal('svcModal');
toast('خدمت ذخیره شد ✓');
loadServices();
}
async function deleteSvc(id) {
if (!confirm('حذف شود؟')) return;
await api(`/api/services/${id}`,{method:'DELETE'});
toast('حذف شد','error');
loadServices();
}
// ── Gallery ───────────────────────────────────────────────────────────────────
let gals=[];
async function loadGallery() {
gals = await api('/api/gallery/all') || [];
document.getElementById('galleryTable').innerHTML = gals.map(g=>`
<tr>
<td><span class="badge badge-gold">${g.category||'—'}</span></td>
<td>${g.caption||'—'}</td>
<td><span class="badge ${g.isActive?'badge-green':'badge-red'}">${g.isActive?'فعال':'غیرفعال'}</span></td>
<td><button class="btn btn-secondary btn-sm" onclick="editGallery(${g.id})">ویرایش</button> <button class="btn btn-danger btn-sm" onclick="deleteGallery(${g.id})">حذف</button></td>
</tr>`).join('');
}
function openGalleryModal() {
['id','img','before','after','cat','caption'].forEach(k=>document.getElementById(`gal-${k}`).value='');
document.getElementById('gal-order').value=gals.length+1;
document.getElementById('gal-active').value='true';
document.getElementById('galleryModal').classList.remove('hidden');
}
function editGallery(id) {
const g=gals.find(x=>x.id===id);
document.getElementById('gal-id').value=g.id;
document.getElementById('gal-img').value=g.imageUrl;
document.getElementById('gal-before').value=g.beforeImageUrl;
document.getElementById('gal-after').value=g.afterImageUrl;
document.getElementById('gal-cat').value=g.category;
document.getElementById('gal-caption').value=g.caption;
document.getElementById('gal-order').value=g.order;
document.getElementById('gal-active').value=String(g.isActive);
document.getElementById('galleryModal').classList.remove('hidden');
}
async function saveGallery() {
const id=document.getElementById('gal-id').value;
const body={imageUrl:document.getElementById('gal-img').value,beforeImageUrl:document.getElementById('gal-before').value,afterImageUrl:document.getElementById('gal-after').value,category:document.getElementById('gal-cat').value,caption:document.getElementById('gal-caption').value,order:parseInt(document.getElementById('gal-order').value),isActive:document.getElementById('gal-active').value==='true'};
if(id) await api(`/api/gallery/${id}`,{method:'PUT',body:JSON.stringify(body)});
else await api('/api/gallery',{method:'POST',body:JSON.stringify(body)});
closeModal('galleryModal'); toast('ذخیره شد ✓'); loadGallery();
}
async function deleteGallery(id){if(!confirm('حذف؟'))return;await api(`/api/gallery/${id}`,{method:'DELETE'});toast('حذف شد','error');loadGallery();}
// ── Testimonials ──────────────────────────────────────────────────────────────
let testims=[];
async function loadTestimonials() {
testims=await api('/api/testimonials/all')||[];
document.getElementById('testimonialsTable').innerHTML=testims.map(t=>`
<tr>
<td>${t.authorEmoji} ${t.authorName}</td>
<td style="max-width:250px">${t.text.substring(0,60)}...</td>
<td>${'★'.repeat(t.rating)}</td>
<td><span class="badge ${t.isActive?'badge-green':'badge-red'}">${t.isActive?'فعال':'غیرفعال'}</span></td>
<td><button class="btn btn-secondary btn-sm" onclick="editTestim(${t.id})">ویرایش</button> <button class="btn btn-danger btn-sm" onclick="deleteTestim(${t.id})">حذف</button></td>
</tr>`).join('');
}
function openTestimModal(){['id','name','emoji','text','date'].forEach(k=>document.getElementById(`testim-${k}`).value='');document.getElementById('testim-emoji').value='👩';document.getElementById('testim-rating').value=5;document.getElementById('testim-active').value='true';document.getElementById('testimModal').classList.remove('hidden');}
function editTestim(id){const t=testims.find(x=>x.id===id);document.getElementById('testim-id').value=t.id;document.getElementById('testim-name').value=t.authorName;document.getElementById('testim-emoji').value=t.authorEmoji;document.getElementById('testim-text').value=t.text;document.getElementById('testim-rating').value=t.rating;document.getElementById('testim-date').value=t.date;document.getElementById('testim-active').value=String(t.isActive);document.getElementById('testimModal').classList.remove('hidden');}
async function saveTestim(){const id=document.getElementById('testim-id').value;const body={authorName:document.getElementById('testim-name').value,authorEmoji:document.getElementById('testim-emoji').value,text:document.getElementById('testim-text').value,rating:parseInt(document.getElementById('testim-rating').value),date:document.getElementById('testim-date').value,isActive:document.getElementById('testim-active').value==='true'};if(id)await api(`/api/testimonials/${id}`,{method:'PUT',body:JSON.stringify(body)});else await api('/api/testimonials',{method:'POST',body:JSON.stringify(body)});closeModal('testimModal');toast('ذخیره شد ✓');loadTestimonials();}
async function deleteTestim(id){if(!confirm('حذف؟'))return;await api(`/api/testimonials/${id}`,{method:'DELETE'});toast('حذف شد','error');loadTestimonials();}
// ── Blog Categories ───────────────────────────────────────────────────────────
let cats=[];
async function loadCategories(){
cats=await api('/api/blog/categories')||[];
document.getElementById('categoriesTable').innerHTML=cats.map(c=>`<tr><td><strong>${c.name}</strong></td><td dir="ltr" style="color:var(--light)">${c.slug}</td><td><span class="badge badge-gold">${c.postCount}</span></td><td><button class="btn btn-secondary btn-sm" onclick="editCat(${c.id})">ویرایش</button> <button class="btn btn-danger btn-sm" onclick="deleteCat(${c.id})">حذف</button></td></tr>`).join('');
}
function openCatModal(){document.getElementById('catModalTitle').textContent='دسته جدید';['id','name','slug','desc'].forEach(k=>document.getElementById(`cat-${k}`).value='');document.getElementById('catModal').classList.remove('hidden');}
function editCat(id){const c=cats.find(x=>x.id===id);document.getElementById('catModalTitle').textContent='ویرایش دسته';document.getElementById('cat-id').value=c.id;document.getElementById('cat-name').value=c.name;document.getElementById('cat-slug').value=c.slug;document.getElementById('cat-desc').value=c.description;document.getElementById('catModal').classList.remove('hidden');}
async function saveCat(){const id=document.getElementById('cat-id').value;const body={name:document.getElementById('cat-name').value,slug:document.getElementById('cat-slug').value,description:document.getElementById('cat-desc').value};if(id)await api(`/api/blog/categories/${id}`,{method:'PUT',body:JSON.stringify(body)});else await api('/api/blog/categories',{method:'POST',body:JSON.stringify(body)});closeModal('catModal');toast('ذخیره شد ✓');loadCategories();}
async function deleteCat(id){if(!confirm('حذف؟'))return;await api(`/api/blog/categories/${id}`,{method:'DELETE'});toast('حذف شد','error');loadCategories();}
// ── Blog Posts ────────────────────────────────────────────────────────────────
let posts=[];
async function loadPosts(){
posts=await api('/api/blog/posts/admin')||[];
document.getElementById('postsTable').innerHTML=posts.map(p=>`
<tr>
<td><strong>${p.title}</strong></td>
<td><span class="badge badge-gold">${p.category?.name||'—'}</span></td>
<td style="color:var(--gold);font-size:.8rem">${p.focusKeyword||'—'}</td>
<td>${p.viewCount}</td>
<td><span class="badge ${p.isPublished?'badge-green':'badge-red'}">${p.isPublished?'منتشر':'پیش‌نویس'}</span></td>
<td>
<button class="btn btn-secondary btn-sm" onclick="editPost(${p.id})">ویرایش</button>
<a href="../post.html?slug=${p.slug}" target="_blank" class="btn btn-secondary btn-sm">مشاهده</a>
<button class="btn btn-danger btn-sm" onclick="deletePost(${p.id})">حذف</button>
</td>
</tr>`).join('');
}
async function openPostEditor(){
if(!cats.length) await loadCategories();
const catSel=document.getElementById('post-category');
catSel.innerHTML='<option value="">انتخاب دسته...</option>'+cats.map(c=>`<option value="${c.id}">${c.name}</option>`).join('');
document.getElementById('postModalTitle').textContent='مقاله جدید';
['id','title','slug','focus','metatitle','metadesc','keywords','image'].forEach(k=>document.getElementById(`post-${k}`).value='');
document.getElementById('post-content').innerHTML='';
document.getElementById('post-excerpt').value='';
document.getElementById('post-published').value='false';
document.getElementById('post-schematype').value='MedicalWebPage';
document.getElementById('post-category').value='';
updateLen('post-metatitle','mtLen',70);
updateLen('post-metadesc','mdLen',160);
document.getElementById('postModal').classList.remove('hidden');
}
async function editPost(id){
await openPostEditor();
const p=await api(`/api/blog/posts/id/${id}`);
if(!p)return;
document.getElementById('postModalTitle').textContent='ویرایش مقاله';
document.getElementById('post-id').value=p.id;
document.getElementById('post-title').value=p.title;
document.getElementById('post-slug').value=p.slug;
document.getElementById('post-excerpt').value=p.excerpt;
document.getElementById('post-content').innerHTML=p.content;
document.getElementById('post-focus').value=p.focusKeyword;
document.getElementById('post-metatitle').value=p.metaTitle;
document.getElementById('post-metadesc').value=p.metaDescription;
document.getElementById('post-keywords').value=p.keywords;
document.getElementById('post-image').value=p.featuredImage;
document.getElementById('post-published').value=String(p.isPublished);
document.getElementById('post-schematype').value=p.articleType;
document.getElementById('post-category').value=p.categoryId||'';
updateLen('post-metatitle','mtLen',70);
updateLen('post-metadesc','mdLen',160);
checkSeo();
}
async function savePost(){
const id=document.getElementById('post-id').value;
const catId=document.getElementById('post-category').value;
const body={
title:document.getElementById('post-title').value,
slug:document.getElementById('post-slug').value,
excerpt:document.getElementById('post-excerpt').value,
content:document.getElementById('post-content').innerHTML,
featuredImage:document.getElementById('post-image').value,
author:'دکتر سوسن آل‌طه',
metaTitle:document.getElementById('post-metatitle').value,
metaDescription:document.getElementById('post-metadesc').value,
focusKeyword:document.getElementById('post-focus').value,
keywords:document.getElementById('post-keywords').value,
articleType:document.getElementById('post-schematype').value,
isPublished:document.getElementById('post-published').value==='true',
categoryId:catId?parseInt(catId):null,
ogImage:document.getElementById('post-image').value,
};
if(id) await api(`/api/blog/posts/${id}`,{method:'PUT',body:JSON.stringify(body)});
else await api('/api/blog/posts',{method:'POST',body:JSON.stringify(body)});
closeModal('postModal'); toast('مقاله ذخیره شد ✓'); loadPosts();
}
async function deletePost(id){if(!confirm('حذف مقاله؟'))return;await api(`/api/blog/posts/${id}`,{method:'DELETE'});toast('مقاله حذف شد','error');loadPosts();}
// ── SEO ───────────────────────────────────────────────────────────────────────
async function loadSeo(){
const s=await api('/api/seo/stats');
if(!s)return;
document.getElementById('seo-posts').textContent=s.total;
document.getElementById('seo-views').textContent=s.views;
document.getElementById('seo-nometa').textContent=s.noMeta;
document.getElementById('seoTopPosts').innerHTML=s.topPosts.map(p=>`<tr><td>${p.title}</td><td><span class="badge badge-gold">${p.viewCount}</span></td></tr>`).join('');
}
// ── SEO helpers ───────────────────────────────────────────────────────────────
function autoSlug(){
const t=document.getElementById('post-title').value;
const s=t.trim().replace(/\s+/g,'-').replace(/[^؀-ۿa-z0-9\-]/g,'').toLowerCase();
document.getElementById('post-slug').value=s;
checkSeo();
}
function updateLen(inputId,labelId,max){
const el=document.getElementById(inputId);
const len=el.value.length;
const label=document.getElementById(labelId);
label.textContent=`(${len}/${max})`;
label.style.color=len>max?'var(--danger)':(len>max*.8?'var(--gold)':'var(--light)');
}
function checkSeo(){
const kw=document.getElementById('post-focus').value.trim();
const title=document.getElementById('post-title').value;
const mt=document.getElementById('post-metatitle').value;
const md=document.getElementById('post-metadesc').value;
const fb=document.getElementById('seoFeedback');
if(!kw){fb.innerHTML='';return;}
const checks=[
{ok:title.includes(kw), msg:'کلیدواژه در عنوان'},
{ok:mt.includes(kw), msg:'کلیدواژه در Meta Title'},
{ok:md.includes(kw), msg:'کلیدواژه در Meta Description'},
{ok:mt.length<=70&&mt.length>0, msg:'طول Meta Title مناسب'},
{ok:md.length<=160&&md.length>100, msg:'طول Meta Description مناسب'},
];
const pass=checks.filter(c=>c.ok).length;
const cls=pass>=4?'good':pass>=2?'ok':'bad';
const emoji=pass>=4?'✅':pass>=2?'⚠️':'❌';
fb.innerHTML=`<div class="seo-score ${cls}">${emoji} امتیاز SEO: ${pass}/${checks.length}${checks.map(c=>`<span style="opacity:${c.ok?1:.4}">${c.ok?'✓':'✗'} ${c.msg}</span>`).join(' | ')}</div>`;
}
// ── Editor helpers ────────────────────────────────────────────────────────────
function fmt(cmd){document.getElementById('post-content').focus();document.execCommand(cmd);}
function fmtBlock(tag){document.getElementById('post-content').focus();document.execCommand('formatBlock',false,tag);}
function insLink(){const url=prompt('آدرس لینک:');if(url){document.getElementById('post-content').focus();document.execCommand('createLink',false,url);}}
// ── Modal helpers ─────────────────────────────────────────────────────────────
function closeModal(id){document.getElementById(id).classList.add('hidden');}
document.querySelectorAll('.modal-overlay').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.add('hidden');}));
// ── Init ──────────────────────────────────────────────────────────────────────
async function init(){
if(!token){return;}
document.getElementById('loginScreen').classList.add('hidden');
loadDashboard();
loadCategories();
}
// Auto-login if token exists
if(token) init();
</script>
</body>
</html>
+179
View File
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>وبلاگ | دکتر سوسن آل‌طه</title>
<meta name="description" content="مقالات تخصصی دکتر سوسن آل‌طه درباره زیبایی پوست، بوتاکس، فیلر، لیزر و مراقبت از پوست."/>
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" onload="this.rel='stylesheet'"/>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--gold:#B8955A;--gold-l:#D4B483;--gold-pale:#F5ECD8;--bg:#FAFAF7;--white:#fff;--dark:#1E1E1E;--mid:#5A5A5A;--light:#9A9A9A;--border:#E8E2D9}
body{font-family:'Vazirmatn',Tahoma,sans-serif;background:var(--bg);color:var(--dark);direction:rtl;line-height:1.8}
a{text-decoration:none;color:inherit}
/* Navbar */
header{position:sticky;top:0;z-index:50;background:rgba(250,250,247,.92);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 2rem;height:70px;display:flex;align-items:center;justify-content:space-between}
.logo{font-size:1rem;font-weight:600;color:var(--gold)}
nav{display:flex;gap:2rem}
nav a{color:var(--mid);font-size:.88rem;transition:color .2s}
nav a:hover{color:var(--gold)}
.nav-cta{background:var(--gold);color:#fff!important;padding:.4rem 1.1rem;border-radius:50px;font-size:.85rem!important}
/* Hero */
.blog-hero{background:linear-gradient(135deg,var(--gold-pale) 0%,#EDE0CA 100%);padding:4rem 2rem 3rem;text-align:center}
.blog-hero h1{font-size:clamp(1.8rem,4vw,2.5rem);font-weight:700;color:var(--dark);margin-bottom:.6rem}
.blog-hero p{font-size:1rem;color:var(--mid);max-width:520px;margin:0 auto}
/* Filter */
.filter-bar{max-width:1100px;margin:2rem auto 0;padding:0 2rem;display:flex;gap:.6rem;flex-wrap:wrap}
.filter-btn{background:transparent;border:1.5px solid var(--border);color:var(--mid);padding:.4rem 1.1rem;border-radius:50px;font-family:inherit;font-size:.85rem;cursor:pointer;transition:all .2s}
.filter-btn.active,.filter-btn:hover{background:var(--gold);border-color:var(--gold);color:#fff}
/* Grid */
.blog-grid{max-width:1100px;margin:2rem auto;padding:0 2rem;display:grid;grid-template-columns:repeat(3,1fr);gap:1.5rem}
@media(max-width:900px){.blog-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:600px){.blog-grid{grid-template-columns:1fr}}
.post-card{background:var(--white);border-radius:16px;border:1px solid var(--border);overflow:hidden;transition:transform .3s,box-shadow .3s;display:flex;flex-direction:column}
.post-card:hover{transform:translateY(-4px);box-shadow:0 12px 40px rgba(184,149,90,.15)}
.post-card-img{aspect-ratio:16/9;background:linear-gradient(135deg,var(--gold-pale),#EDE0CA);display:flex;align-items:center;justify-content:center;color:var(--gold);font-size:2rem}
.post-card-img img{width:100%;height:100%;object-fit:cover}
.post-card-body{padding:1.3rem;flex:1;display:flex;flex-direction:column;gap:.6rem}
.post-cat{font-size:.72rem;font-weight:600;color:var(--gold);background:var(--gold-pale);padding:.2rem .7rem;border-radius:50px;display:inline-block}
.post-title{font-size:1rem;font-weight:600;color:var(--dark);line-height:1.5}
.post-title:hover{color:var(--gold)}
.post-excerpt{font-size:.85rem;color:var(--mid);line-height:1.7;flex:1}
.post-meta{display:flex;align-items:center;justify-content:space-between;font-size:.75rem;color:var(--light);margin-top:auto;padding-top:.6rem;border-top:1px solid var(--border)}
.read-more{color:var(--gold);font-weight:500;font-size:.82rem}
/* Pagination */
.pagination{display:flex;gap:.5rem;justify-content:center;padding:2rem;margin-top:1rem}
.page-btn{width:38px;height:38px;border-radius:8px;border:1.5px solid var(--border);background:transparent;font-family:inherit;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s}
.page-btn.active,.page-btn:hover{background:var(--gold);border-color:var(--gold);color:#fff}
/* Empty */
.empty{text-align:center;padding:4rem 2rem;color:var(--light)}
.empty svg{width:48px;height:48px;margin:0 auto 1rem}
/* Search */
.search-wrap{max-width:480px;margin:0 auto;position:relative}
.search-wrap input{width:100%;border:1.5px solid var(--border);border-radius:50px;padding:.65rem 1.2rem .65rem 3rem;font-family:inherit;font-size:.9rem;direction:rtl;outline:none;background:var(--white);transition:border-color .2s}
.search-wrap input:focus{border-color:var(--gold)}
.search-wrap svg{position:absolute;left:1rem;top:50%;transform:translateY(-50%);width:18px;height:18px;color:var(--light)}
footer{background:var(--dark);color:rgba(255,255,255,.5);text-align:center;padding:2rem;font-size:.83rem;margin-top:3rem}
footer a{color:var(--gold-l)}
</style>
</head>
<body>
<header>
<a class="logo" href="index.html">دکتر سوسن آل‌طه</a>
<nav>
<a href="index.html">خانه</a>
<a href="index.html#services">خدمات</a>
<a href="blog.html" style="color:var(--gold)">وبلاگ</a>
<a href="index.html#contact" class="nav-cta">رزرو نوبت</a>
</nav>
</header>
<div class="blog-hero">
<h1>وبلاگ تخصصی پوست و زیبایی</h1>
<p>آخرین مقالات و راهنماهای تخصصی درباره مراقبت از پوست، زیبایی و درمان‌های تخصصی</p>
<div class="search-wrap" style="margin-top:1.5rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="searchInput" placeholder="جستجو در مقالات..." oninput="handleSearch()"/>
</div>
</div>
<div class="filter-bar" id="filterBar">
<button class="filter-btn active" onclick="filterCat('',this)">همه</button>
</div>
<div class="blog-grid" id="blogGrid">
<div class="empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16"/><path d="M20 8H8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2z"/></svg><p>در حال بارگذاری...</p></div>
</div>
<div class="pagination" id="pagination"></div>
<footer>
<p>© ۱۴۰۳ دکتر سوسن آل‌طه | <a href="admin/index.html">پنل مدیریت</a></p>
</footer>
<script>
const API='http://localhost:5000';
let currentCat='', currentSearch='', currentPage=1, pageSize=9, totalPosts=0;
async function loadPosts(){
const params=new URLSearchParams({page:currentPage,pageSize});
if(currentCat) params.set('category',currentCat);
if(currentSearch) params.set('search',currentSearch);
const data=await fetch(`${API}/api/blog/posts?${params}`).then(r=>r.json()).catch(()=>null);
if(!data){renderError();return;}
totalPosts=data.total;
renderGrid(data.items);
renderPagination();
}
function renderGrid(posts){
const g=document.getElementById('blogGrid');
if(!posts.length){g.innerHTML='<div class="empty"><p>مقاله‌ای یافت نشد</p></div>';return;}
g.innerHTML=posts.map(p=>`
<div class="post-card">
<div class="post-card-img">${p.featuredImage?`<img src="${p.featuredImage}" alt="${p.title}" loading="lazy"/>`:'📝'}</div>
<div class="post-card-body">
<span class="post-cat">${p.category?.name||'عمومی'}</span>
<a href="post.html?slug=${p.slug}" class="post-title">${p.title}</a>
<p class="post-excerpt">${p.excerpt.substring(0,120)}...</p>
<div class="post-meta">
<span>🕐 ${p.readingTimeMinutes} دقیقه | 👁 ${p.viewCount}</span>
<a href="post.html?slug=${p.slug}" class="read-more">ادامه مطلب ←</a>
</div>
</div>
</div>`).join('');
}
function renderPagination(){
const total=Math.ceil(totalPosts/pageSize);
const pg=document.getElementById('pagination');
if(total<=1){pg.innerHTML='';return;}
let h='';
for(let i=1;i<=total;i++) h+=`<button class="page-btn${i===currentPage?' active':''}" onclick="gotoPage(${i})">${i}</button>`;
pg.innerHTML=h;
}
function renderError(){document.getElementById('blogGrid').innerHTML='<div class="empty"><p>خطا در اتصال به سرور. مطمئن شوید API روی پورت ۵۰۰۰ اجرا است.</p></div>';}
function filterCat(slug,btn){
currentCat=slug; currentPage=1;
document.querySelectorAll('.filter-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
loadPosts();
}
let searchTimer;
function handleSearch(){
clearTimeout(searchTimer);
searchTimer=setTimeout(()=>{currentSearch=document.getElementById('searchInput').value;currentPage=1;loadPosts();},400);
}
function gotoPage(p){currentPage=p;loadPosts();window.scrollTo({top:200,behavior:'smooth'});}
async function loadCategories(){
const cats=await fetch(`${API}/api/blog/categories`).then(r=>r.json()).catch(()=>[]);
const bar=document.getElementById('filterBar');
cats.forEach(c=>{
const btn=document.createElement('button');
btn.className='filter-btn';
btn.textContent=`${c.name} (${c.postCount})`;
btn.onclick=()=>filterCat(c.slug,btn);
bar.appendChild(btn);
});
}
loadCategories();
loadPosts();
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"insecure-registries": ["171.22.25.73:8087"],
"registry-mirrors": [
"https://docker-mirror.liara.ir",
"https://ghcr-mirror.liara.ir",
"https://mcr-mirror.liara.ir"
]
}
+36
View File
@@ -0,0 +1,36 @@
services:
# ── .NET API + Razor Pages + Static Files ────────────────────────────────────
api:
image: 171.22.25.73:8087/drsousan/api:${API_TAG:-latest}
build: # used by local: docker compose build
context: ./DrSousan.Api
dockerfile: Dockerfile
container_name: drsousan_api
restart: unless-stopped
ports:
- "${HOST_PORT:-5000}:8080" # http://localhost:5000
volumes:
- db_data:/data # SQLite database (persistent)
- uploads_data:/app/wwwroot/uploads # user-uploaded images (persistent)
environment:
# Database — SQLite stored on /data volume
ConnectionStrings__Default: "Data Source=/data/drsousan.db"
# JWT — CHANGE Jwt__Key in production (min 32 chars)
Jwt__Key: "${JWT_KEY:-DrSousanSecretKey2024!ChangeThisInProduction!MinLength32Chars}"
Jwt__Issuer: "${JWT_ISSUER:-DrSousanApi}"
Jwt__Audience: "${JWT_AUDIENCE:-DrSousanAdmin}"
# Admin login — override via .env file in production
Admin__Username: "${ADMIN_USERNAME:-admin}"
Admin__Password: "${ADMIN_PASSWORD:-admin123}"
ASPNETCORE_ENVIRONMENT: "Production"
# ── Named Volumes ────────────────────────────────────────────────────────────
volumes:
db_data:
driver: local
uploads_data:
driver: local
+1483
View File
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<!-- SEO meta tags injected by JS -->
<title id="pageTitle">مقاله | دکتر سوسن آل‌طه</title>
<meta id="metaDesc" name="description" content=""/>
<meta id="metaKw" name="keywords" content=""/>
<!-- Open Graph -->
<meta id="ogTitle" property="og:title" content=""/>
<meta id="ogDesc" property="og:description" content=""/>
<meta id="ogImg" property="og:image" content=""/>
<meta property="og:type" content="article"/>
<meta property="og:locale" content="fa_IR"/>
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image"/>
<meta id="twTitle" name="twitter:title" content=""/>
<meta id="twDesc" name="twitter:description" content=""/>
<!-- Canonical -->
<link id="canonical" rel="canonical" href=""/>
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" onload="this.rel='stylesheet'"/>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--gold:#B8955A;--gold-l:#D4B483;--gold-pale:#F5ECD8;--bg:#FAFAF7;--white:#fff;--dark:#1E1E1E;--mid:#5A5A5A;--light:#9A9A9A;--border:#E8E2D9}
body{font-family:'Vazirmatn',Tahoma,sans-serif;background:var(--bg);color:var(--dark);direction:rtl;line-height:1.8}
a{text-decoration:none;color:inherit}
header{position:sticky;top:0;z-index:50;background:rgba(250,250,247,.92);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 2rem;height:70px;display:flex;align-items:center;justify-content:space-between}
.logo{font-size:1rem;font-weight:600;color:var(--gold)}
nav{display:flex;gap:2rem}
nav a{color:var(--mid);font-size:.88rem;transition:color .2s}
nav a:hover{color:var(--gold)}
.nav-cta{background:var(--gold);color:#fff!important;padding:.4rem 1.1rem;border-radius:50px;font-size:.85rem!important}
/* Layout */
.post-layout{max-width:1100px;margin:0 auto;padding:3rem 2rem;display:grid;grid-template-columns:1fr 320px;gap:3rem;align-items:start}
@media(max-width:900px){.post-layout{grid-template-columns:1fr}}
/* Article */
.article-hero{border-radius:16px;overflow:hidden;margin-bottom:2rem;aspect-ratio:16/6;background:linear-gradient(135deg,var(--gold-pale),#EDE0CA);display:flex;align-items:center;justify-content:center;font-size:3rem}
.article-hero img{width:100%;height:100%;object-fit:cover}
.article-cat{display:inline-block;background:var(--gold-pale);color:var(--gold);font-size:.75rem;font-weight:600;padding:.25rem .8rem;border-radius:50px;margin-bottom:.8rem}
.article-title{font-size:clamp(1.5rem,3vw,2rem);font-weight:700;line-height:1.4;margin-bottom:1rem}
.article-meta{display:flex;gap:1.5rem;font-size:.8rem;color:var(--light);margin-bottom:2rem;padding-bottom:1.5rem;border-bottom:1px solid var(--border);flex-wrap:wrap}
.article-meta span{display:flex;align-items:center;gap:.3rem}
/* Content */
.article-content{font-size:.95rem;line-height:2;color:var(--dark)}
.article-content h2{font-size:1.3rem;font-weight:700;margin:1.8rem 0 .8rem;color:var(--dark);padding-bottom:.4rem;border-bottom:2px solid var(--gold-pale)}
.article-content h3{font-size:1.1rem;font-weight:600;margin:1.4rem 0 .6rem;color:var(--dark)}
.article-content p{margin-bottom:1rem}
.article-content ul,.article-content ol{padding-right:1.5rem;margin-bottom:1rem}
.article-content li{margin-bottom:.4rem}
.article-content strong{color:var(--dark);font-weight:600}
.article-content a{color:var(--gold);border-bottom:1px solid var(--gold-pale)}
.article-content blockquote{border-right:4px solid var(--gold);padding:.8rem 1.2rem;background:var(--gold-pale);border-radius:0 8px 8px 0;margin:1.2rem 0;font-style:italic;color:var(--mid)}
/* Tags */
.article-tags{margin-top:2rem;padding-top:1.5rem;border-top:1px solid var(--border);display:flex;gap:.5rem;flex-wrap:wrap}
.tag{background:var(--bg);border:1px solid var(--border);padding:.25rem .75rem;border-radius:50px;font-size:.78rem;color:var(--mid)}
/* Share */
.share-box{margin-top:2rem;padding:1.5rem;background:var(--white);border-radius:12px;border:1px solid var(--border);text-align:center}
.share-title{font-size:.9rem;font-weight:600;margin-bottom:1rem}
.share-btns{display:flex;gap:.6rem;justify-content:center;flex-wrap:wrap}
.share-btn{padding:.45rem 1rem;border-radius:8px;font-family:inherit;font-size:.82rem;cursor:pointer;border:none;font-weight:500}
.share-telegram{background:#2CA5E0;color:#fff}
.share-whatsapp{background:#25D366;color:#fff}
.share-copy{background:var(--bg);color:var(--mid);border:1px solid var(--border)}
/* CTA */
.cta-box{margin-top:2.5rem;padding:2rem;background:linear-gradient(135deg,var(--gold-pale),#EDE0CA);border-radius:16px;text-align:center}
.cta-box h3{font-size:1.1rem;font-weight:700;margin-bottom:.6rem}
.cta-box p{font-size:.88rem;color:var(--mid);margin-bottom:1.2rem}
.cta-btn{background:var(--gold);color:#fff;padding:.7rem 1.8rem;border-radius:50px;font-family:inherit;font-size:.9rem;font-weight:600;border:none;cursor:pointer;display:inline-block}
/* Sidebar */
.sidebar{position:sticky;top:90px}
.sidebar-card{background:var(--white);border-radius:14px;border:1px solid var(--border);padding:1.4rem;margin-bottom:1.5rem}
.sidebar-title{font-size:.88rem;font-weight:700;color:var(--dark);margin-bottom:1rem;padding-bottom:.6rem;border-bottom:1px solid var(--border)}
.recent-post{display:flex;gap:.8rem;margin-bottom:.9rem;padding-bottom:.9rem;border-bottom:1px solid var(--border)}
.recent-post:last-child{margin-bottom:0;padding-bottom:0;border-bottom:none}
.recent-img{width:56px;height:56px;border-radius:8px;background:var(--gold-pale);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:1.3rem}
.recent-img img{width:100%;height:100%;object-fit:cover;border-radius:8px}
.recent-title{font-size:.82rem;font-weight:600;line-height:1.4;color:var(--dark)}
.recent-title:hover{color:var(--gold)}
.recent-date{font-size:.73rem;color:var(--light);margin-top:.2rem}
.doctor-card{text-align:center}
.doc-avatar{width:80px;height:80px;border-radius:50%;background:var(--gold-pale);margin:0 auto .8rem;display:flex;align-items:center;justify-content:center;font-size:2rem}
.doc-name{font-size:.95rem;font-weight:700;color:var(--dark)}
.doc-title{font-size:.78rem;color:var(--light);margin:.2rem 0 .8rem}
.doc-btn{background:var(--gold);color:#fff;padding:.5rem 1.2rem;border-radius:50px;font-family:inherit;font-size:.82rem;border:none;cursor:pointer;width:100%}
/* Loading */
.loading{text-align:center;padding:5rem 2rem;color:var(--light)}
footer{background:var(--dark);color:rgba(255,255,255,.5);text-align:center;padding:2rem;font-size:.83rem;margin-top:3rem}
footer a{color:var(--gold-l)}
</style>
</head>
<body>
<header>
<a class="logo" href="index.html">دکتر سوسن آل‌طه</a>
<nav>
<a href="index.html">خانه</a>
<a href="blog.html">وبلاگ</a>
<a href="index.html#contact" class="nav-cta">رزرو نوبت</a>
</nav>
</header>
<div id="loadingState" class="loading"><p>در حال بارگذاری...</p></div>
<div id="postLayout" class="post-layout" style="display:none">
<!-- Article -->
<article>
<div class="article-hero" id="articleHero">📝</div>
<span class="article-cat" id="articleCat">زیبایی پوست</span>
<h1 class="article-title" id="articleTitle"></h1>
<div class="article-meta">
<span>✍️ <span id="articleAuthor">دکتر سوسن آل‌طه</span></span>
<span>📅 <span id="articleDate"></span></span>
<span>🕐 <span id="articleTime"></span> دقیقه مطالعه</span>
<span>👁 <span id="articleViews"></span> بازدید</span>
</div>
<div class="article-content" id="articleContent"></div>
<div class="article-tags" id="articleTags"></div>
<div class="share-box">
<div class="share-title">این مقاله را به اشتراک بگذارید</div>
<div class="share-btns">
<button class="share-btn share-telegram" onclick="shareTo('telegram')">📱 تلگرام</button>
<button class="share-btn share-whatsapp" onclick="shareTo('whatsapp')">💬 واتساپ</button>
<button class="share-btn share-copy" onclick="copyLink()">🔗 کپی لینک</button>
</div>
</div>
<div class="cta-box">
<h3>آماده تحول در پوست‌تان هستید؟</h3>
<p>همین امروز با دکتر سوسن آل‌طه مشاوره رایگان دریافت کنید</p>
<a href="index.html#contact" class="cta-btn">رزرو نوبت رایگان</a>
</div>
</article>
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-card doctor-card">
<div class="doc-avatar">👩‍⚕️</div>
<div class="doc-name">دکتر سوسن آل‌طه</div>
<div class="doc-title">پزشک عمومی | متخصص زیبایی پوست</div>
<button class="doc-btn" onclick="location.href='index.html#contact'">رزرو نوبت</button>
</div>
<div class="sidebar-card">
<div class="sidebar-title">مقالات اخیر</div>
<div id="recentPosts"></div>
</div>
<div class="sidebar-card">
<div class="sidebar-title">خدمات ما</div>
<div style="display:flex;flex-direction:column;gap:.5rem;font-size:.85rem">
<a href="index.html#services" style="color:var(--mid);display:flex;align-items:center;gap:.4rem"><span style="color:var(--gold)"></span> بوتاکس و فیلر</a>
<a href="index.html#services" style="color:var(--mid);display:flex;align-items:center;gap:.4rem"><span style="color:var(--gold)"></span> لیزر درمانی</a>
<a href="index.html#services" style="color:var(--mid);display:flex;align-items:center;gap:.4rem"><span style="color:var(--gold)"></span> مزوتراپی</a>
<a href="index.html#services" style="color:var(--mid);display:flex;align-items:center;gap:.4rem"><span style="color:var(--gold)"></span> پاکسازی پوست</a>
<a href="index.html#services" style="color:var(--mid);display:flex;align-items:center;gap:.4rem"><span style="color:var(--gold)"></span> مشاوره زیبایی</a>
</div>
</div>
</aside>
</div>
<div id="errorState" style="display:none;text-align:center;padding:5rem 2rem">
<p style="color:#999;font-size:1.1rem">مقاله یافت نشد</p>
<a href="blog.html" style="color:var(--gold);margin-top:1rem;display:inline-block">← بازگشت به وبلاگ</a>
</div>
<footer>
<p>© ۱۴۰۳ دکتر سوسن آل‌طه | <a href="blog.html">وبلاگ</a> | <a href="admin/index.html">پنل مدیریت</a></p>
</footer>
<script>
const API='http://localhost:5000';
const slug=new URLSearchParams(location.search).get('slug');
function fa(n){return String(n).replace(/\d/g,d=>'۰۱۲۳۴۵۶۷۸۹'[d]);}
function fmtDate(d){if(!d)return'';const dt=new Date(d);const months=['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'];const m=months[dt.getMonth()]||'';return `${fa(dt.getDate())} ${m} ${fa(dt.getFullYear())}`;}
async function loadPost(){
if(!slug){showError();return;}
try{
const post=await fetch(`${API}/api/blog/posts/${slug}`).then(r=>{if(!r.ok)throw r;return r.json();});
renderPost(post);
injectSeo(post);
loadRecent(post.id);
}catch(e){showError();}
}
function renderPost(p){
document.getElementById('loadingState').style.display='none';
document.getElementById('postLayout').style.display='grid';
if(p.featuredImage) document.getElementById('articleHero').innerHTML=`<img src="${p.featuredImage}" alt="${p.title}" loading="lazy"/>`;
document.getElementById('articleCat').textContent=p.category?.name||'عمومی';
document.getElementById('articleTitle').textContent=p.title;
document.getElementById('articleAuthor').textContent=p.author;
document.getElementById('articleDate').textContent=fmtDate(p.publishedAt);
document.getElementById('articleTime').textContent=fa(p.readingTimeMinutes);
document.getElementById('articleViews').textContent=fa(p.viewCount);
document.getElementById('articleContent').innerHTML=p.content;
if(p.keywords){
document.getElementById('articleTags').innerHTML=
p.keywords.split(',').filter(Boolean).map(k=>`<span class="tag">${k.trim()}</span>`).join('');
}
}
function injectSeo(p){
document.getElementById('pageTitle').textContent=p.metaTitle||p.title+' | دکتر سوسن آل‌طه';
setMeta('metaDesc',p.metaDescription);
setMeta('metaKw',p.keywords);
setMeta('ogTitle',p.metaTitle||p.title);
setMeta('ogDesc',p.metaDescription);
setMeta('ogImg',p.ogImage||p.featuredImage);
setMeta('twTitle',p.metaTitle||p.title);
setMeta('twDesc',p.metaDescription);
document.getElementById('canonical').href=location.href;
// JSON-LD structured data
const schema={
"@context":"https://schema.org",
"@type":p.articleType||"Article",
"headline":p.title,
"description":p.excerpt,
"author":{"@type":"Person","name":p.author||"دکتر سوسن آل‌طه"},
"publisher":{"@type":"Organization","name":"دکتر سوسن آل‌طه"},
"datePublished":p.publishedAt,
"dateModified":p.updatedAt,
"image":p.featuredImage||"",
"keywords":p.keywords
};
const s=document.createElement('script');
s.type='application/ld+json';
s.textContent=JSON.stringify(schema);
document.head.appendChild(s);
}
function setMeta(id,val){const el=document.getElementById(id);if(el&&val)el.setAttribute(el.hasAttribute('content')?'content':'href',val);}
async function loadRecent(currentId){
const data=await fetch(`${API}/api/blog/posts?page=1&pageSize=5`).then(r=>r.json()).catch(()=>null);
if(!data) return;
const rp=document.getElementById('recentPosts');
rp.innerHTML=data.items.filter(p=>p.id!==currentId).slice(0,4).map(p=>`
<div class="recent-post">
<div class="recent-img">${p.featuredImage?`<img src="${p.featuredImage}" alt="${p.title}" loading="lazy"/>`:'📝'}</div>
<div><a href="post.html?slug=${p.slug}" class="recent-title">${p.title}</a><div class="recent-date">🕐 ${p.readingTimeMinutes} دقیقه</div></div>
</div>`).join('');
}
function showError(){
document.getElementById('loadingState').style.display='none';
document.getElementById('errorState').style.display='block';
}
function shareTo(platform){
const url=encodeURIComponent(location.href);
const title=encodeURIComponent(document.title);
const links={telegram:`https://t.me/share/url?url=${url}&text=${title}`,whatsapp:`https://wa.me/?text=${title}%20${url}`};
window.open(links[platform],'_blank');
}
function copyLink(){
navigator.clipboard.writeText(location.href).then(()=>{
const btn=event.target;btn.textContent='✓ کپی شد!';setTimeout(()=>btn.textContent='🔗 کپی لینک',2000);
});
}
loadPost();
</script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
@echo off
echo ╔════════════════════════════════════════════════╗
echo ║ Dr. Sousan Website ║
echo ║ Site: http://localhost:5000/ ║
echo ║ Blog: http://localhost:5000/blog ║
echo ║ Admin: http://localhost:5000/admin/ ║
echo ╚════════════════════════════════════════════════╝
echo.
cd /d "%~dp0DrSousan.Api"
dotnet run --configuration Development