Browse Source

feat: improve IDE opening reliability, data locator plugin, and click-to-component handling

master
parent
commit
a56036b9c4
  1. 21
      .agents/rules/post_code_change_verification.md
  2. 27
      index.html
  3. 68
      plugins/add-data-locator.cjs
  4. 35
      src/components/utils/dev-click-to-component.tsx
  5. 4
      src/features/services/ServicesPage.tsx
  6. 989
      src/features/services/components/BlogsServiceView.tsx
  7. 74
      src/features/services/services/blogs-api.ts
  8. 3
      src/stores/theme.ts
  9. 62
      vite.config.ts

21
.agents/rules/post_code_change_verification.md

@ -0,0 +1,21 @@
# اعتبارسنجی ران‌تایم و سلامت دیتا پس از تغییر کد (Panel Admin)
این قانون تضمین می‌کند که هیچ تغییری در کدهای پنل مدیریت ادمین (`aqila_panel`)، وضعیت ران بودن پروژه یا جریان داده‌ها را دچار اختلال نکرده و کاربر نیازی به ارسال مجدد ارور در پیام بعدی نداشته باشد.
---
## ۱. شرط فعال‌سازی (Trigger Condition)
- **صرفاً پس از تغییر کد:** این اعتبارسنجی منحصراً زمانی انجام می‌شود که کدی توسط هوش مصنوعی در پنل ویرایش یا ایجاد شده باشد.
- **عدم اجرا در گفتگوهای بدون تغییر کد:** در چت‌های عادی، توضیحی یا پرسش و پاسخ، هیچ اعتبارسنجی و بررسی ران‌تایمی انجام نمی‌شود.
---
## ۲. چک‌لیست اعتبارسنجی در پنل مدیریت
1. **کامپایل تایپ‌اسکریپت و بیلد Vite:** تغییرات نباید باعث خطای تایپ یا کرش HMR در سرور در حال اجرا شوند.
2. **سلامت جریان داده‌ها در جداول و فرم‌ها:** بررسی صحت مدیریت مقادیر نال/آندفایند در کامپوننت‌های داده‌محور (Data Tables, Dropdowns, Forms) تا در مواجهه با دیتای دریافتی کرش نکنند.
3. **سازگاری با سرویس‌های API:** تطابق فراخوانی متدهای رکوئست با مدل‌ها و پاسخ‌های بک‌اند.
---
## ۳. اصل خودترمیمی در همان پیام (Self-Healing)
- هرگونه خطای ناشی از ویرایش کد باید بلافاصله در انتهای همان مرحله شناسایی و اصلاح گردد تا کاربر بدون خطا به کار با پنل ادامه دهد.

27
index.html

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="fa" dir="rtl">
<html lang="fa" dir="rtl" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=aqila2" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg?v=aqila2" />
@ -13,6 +13,31 @@
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" /> <meta name="supported-color-schemes" content="light dark" />
<title>پنل مدیریت کاروان نصرت عقیله زینب (س)</title> <title>پنل مدیریت کاروان نصرت عقیله زینب (س)</title>
<!-- اعمال سریع و پایدار حالت تم در رفرش بدون پرش و ماندگاری در نشست -->
<script>
(function() {
try {
var saved = sessionStorage.getItem('dashboard-theme') || localStorage.getItem('dashboard-theme');
var isDark = true;
if (saved) {
var data = JSON.parse(saved);
if (data && data.state && typeof data.state.isDark === 'boolean') {
isDark = data.state.isDark;
}
}
var root = document.documentElement;
if (isDark) {
root.classList.add('dark');
root.classList.remove('light');
} else {
root.classList.remove('dark');
root.classList.add('light');
}
} catch (e) {
document.documentElement.classList.add('dark');
}
})();
</script>
<!-- اطمینان از اعمال و رفرش آنی فاوآیکون در کش مرورگر --> <!-- اطمینان از اعمال و رفرش آنی فاوآیکون در کش مرورگر -->
<script> <script>
(function() { (function() {

68
plugins/add-data-locator.cjs

@ -1,4 +1,29 @@
module.exports = function addDataLocator({ types: t }) { module.exports = function addDataLocator({ types: t }) {
function getElementName(node) {
if (!node) return "";
if (t.isJSXIdentifier(node)) {
return node.name;
}
if (t.isJSXMemberExpression(node)) {
return `${getElementName(node.object)}.${node.property.name}`;
}
return "";
}
const FRAGMENT_DEFAULT_COMPONENTS = new Set([
"Fragment",
"React.Fragment",
"Tab.Group",
"Tabs",
"Transition",
"Transition.Root",
"Transition.Child",
"Menu",
"Menu.Item",
"Switch.Group",
"Popover.Group",
]);
return { return {
name: "add-data-locator", name: "add-data-locator",
visitor: { visitor: {
@ -13,6 +38,49 @@ module.exports = function addDataLocator({ types: t }) {
return; return;
} }
const elementName = getElementName(path.node.name);
// 1. Skip React Fragment and known Headless UI components that render Fragment
if (
!elementName ||
elementName === "Fragment" ||
elementName.endsWith(".Fragment") ||
FRAGMENT_DEFAULT_COMPONENTS.has(elementName)
) {
return;
}
// 2. Skip any component explicitly rendered as a Fragment (e.g. as={Fragment} or as="Fragment")
const hasFragmentAs = path.node.attributes.some((attribute) => {
if (
t.isJSXAttribute(attribute) &&
t.isJSXIdentifier(attribute.name) &&
attribute.name.name === "as"
) {
if (t.isJSXExpressionContainer(attribute.value)) {
const expr = attribute.value.expression;
if (t.isIdentifier(expr, { name: "Fragment" })) return true;
if (
t.isMemberExpression(expr) &&
t.isIdentifier(expr.property, { name: "Fragment" })
) {
return true;
}
}
if (
t.isStringLiteral(attribute.value) &&
attribute.value.value === "Fragment"
) {
return true;
}
}
return false;
});
if (hasFragmentAs) {
return;
}
const attributeExists = path.node.attributes.some( const attributeExists = path.node.attributes.some(
(attribute) => (attribute) =>
t.isJSXAttribute(attribute) && t.isJSXAttribute(attribute) &&

35
src/components/utils/dev-click-to-component.tsx

@ -97,17 +97,27 @@ export function DevClickToComponent() {
return () => window.removeEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize);
}, [isMobile]); }, [isMobile]);
// Handle keyboard shortcuts (Alt key for hover highlight, Escape to cancel inspect)
// Handle keyboard shortcuts (Alt key for hover highlight, Escape to cancel inspect, screenshot/meta key suppression)
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
setIsInspecting(false); setIsInspecting(false);
setHoverInfo(null); setHoverInfo(null);
} else if (
e.key === "Meta" ||
e.key === "Control" ||
e.key === "Shift" ||
e.key === "PrintScreen"
) {
// Clear selector immediately if Meta (Cmd on Mac) or screenshot keys are pressed
if (!isInspecting) {
setHoverInfo(null);
}
} }
}; };
const handleKeyUp = (e: KeyboardEvent) => { const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Alt" && !isInspecting) {
if (!e.altKey && !isInspecting) {
setHoverInfo(null); setHoverInfo(null);
} }
}; };
@ -118,21 +128,30 @@ export function DevClickToComponent() {
} }
}; };
const handleVisibilityChange = () => {
if (document.hidden && !isInspecting) {
setHoverInfo(null);
}
};
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp); window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur); window.addEventListener("blur", handleBlur);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur); window.removeEventListener("blur", handleBlur);
document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, [isInspecting]); }, [isInspecting]);
// Live hover highlight when Alt is held or Inspect Mode is active
// Live hover highlight when Alt (Option on Mac) is held or Inspect Mode is active
useEffect(() => { useEffect(() => {
const handleMouseMove = (event: MouseEvent) => { const handleMouseMove = (event: MouseEvent) => {
if (!event.altKey && !isInspecting) {
const isModifierActive = event.altKey && !event.metaKey && !event.ctrlKey;
if (!isModifierActive && !isInspecting) {
if (hoverInfo) setHoverInfo(null); if (hoverInfo) setHoverInfo(null);
return; return;
} }
@ -207,7 +226,8 @@ export function DevClickToComponent() {
const cleanPath = filePath.replace(/^[/\\]+/, ""); const cleanPath = filePath.replace(/^[/\\]+/, "");
const uriPath = filePath.startsWith("/") ? filePath : `/${filePath}`; const uriPath = filePath.startsWith("/") ? filePath : `/${filePath}`;
const targetUri = `${uriPath}${positionSuffix}`; const targetUri = `${uriPath}${positionSuffix}`;
const antigravityIdeUrl = `antigravity-ide://file${targetUri}`;
const encodedUri = encodeURI(targetUri);
const antigravityIdeUrl = `antigravity-ide://file${encodedUri}`;
try { try {
const res = await fetch("/api/open-in-ide", { const res = await fetch("/api/open-in-ide", {
@ -229,7 +249,7 @@ export function DevClickToComponent() {
const ideUrl = const ideUrl =
IDE_SCHEMES.find(({ matches }) => IDE_SCHEMES.find(({ matches }) =>
matches.some((match) => userAgent.includes(match)), matches.some((match) => userAgent.includes(match)),
)?.createUrl(targetUri) ?? antigravityIdeUrl;
)?.createUrl(encodedUri) ?? antigravityIdeUrl;
try { try {
window.location.assign(ideUrl); window.location.assign(ideUrl);
@ -246,7 +266,8 @@ export function DevClickToComponent() {
// Click interception (Alt + Click on desktop, or tap in Inspect Mode) // Click interception (Alt + Click on desktop, or tap in Inspect Mode)
useEffect(() => { useEffect(() => {
const handleDocumentClick = (event: MouseEvent) => { const handleDocumentClick = (event: MouseEvent) => {
if (!event.altKey && !isInspecting) {
const isModifierActive = event.altKey && !event.metaKey && !event.ctrlKey;
if (!isModifierActive && !isInspecting) {
return; return;
} }

4
src/features/services/ServicesPage.tsx

@ -251,8 +251,8 @@ export default function ServicesPage() {
return ( return (
<div className="space-y-6 pt-2 pb-14"> <div className="space-y-6 pt-2 pb-14">
{/* سربرگ بخش خدمات (برای شهرهای زیارتی به صورت یکپارچه داخل کامپوننت با دکمه لود مجدد مدیریت می‌شود) */}
{serviceKey !== 'cities' && (
{/* سربرگ بخش خدمات (برای شهرهای زیارتی و وبلاگ به صورت یکپارچه داخل کامپوننت با دکمه‌ها و آمار مدیریت می‌شود) */}
{serviceKey !== 'cities' && serviceKey !== 'blogs' && (
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-5 shadow-xs sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs"> <div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-5 shadow-xs sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">

989
src/features/services/components/BlogsServiceView.tsx
File diff suppressed because it is too large
View File

74
src/features/services/services/blogs-api.ts

@ -26,6 +26,54 @@ export interface BlogItem {
updated_at: string updated_at: string
} }
export interface BlogTranslationData {
id?: number
title: string
slug?: string
summary: string
content: string
tags?: BlogTag[]
tag_names?: string[]
language_code?: string
status?: boolean
cover?: string | null
}
export interface BlogArticleItem {
id: number
group_key: string
base_slug: string
title: string
cover: string | null
cover_input?: string
status: boolean
is_top_list: boolean
created_at: string
updated_at: string
available_languages: string[]
translations: Record<string, BlogTranslationData>
tag_names?: string[]
}
export interface SaveBlogArticlePayload {
id?: number
group_key?: string
base_slug: string
cover?: string
cover_input?: string
status: boolean
is_top_list: boolean
translations: Record<
string,
{
title: string
summary: string
content: string
tag_names?: string[]
}
>
}
export interface BlogListResponse { export interface BlogListResponse {
count?: number count?: number
next?: string | null next?: string | null
@ -38,8 +86,9 @@ export async function fetchAdminBlogs(params?: {
status?: string status?: string
search?: string search?: string
tag?: string tag?: string
}): Promise<BlogItem[]> {
}): Promise<BlogArticleItem[]> {
const query = new URLSearchParams() const query = new URLSearchParams()
query.set('grouped', 'true')
if (params?.language_code && params.language_code !== 'ALL') { if (params?.language_code && params.language_code !== 'ALL') {
query.set('language_code', params.language_code) query.set('language_code', params.language_code)
} }
@ -70,6 +119,20 @@ export async function createAdminBlog(payload: Partial<BlogItem>): Promise<BlogI
}) })
} }
export async function saveAdminArticle(payload: SaveBlogArticlePayload): Promise<BlogArticleItem> {
if (payload.id) {
return await apiFetch<BlogArticleItem>(`blogs/admin/${payload.id}/`, {
method: 'PATCH',
body: payload,
})
} else {
return await apiFetch<BlogArticleItem>('blogs/admin/', {
method: 'POST',
body: payload,
})
}
}
export async function updateAdminBlog(id: number, payload: Partial<BlogItem>): Promise<BlogItem> { export async function updateAdminBlog(id: number, payload: Partial<BlogItem>): Promise<BlogItem> {
return await apiFetch<BlogItem>(`blogs/admin/${id}/`, { return await apiFetch<BlogItem>(`blogs/admin/${id}/`, {
method: 'PATCH', method: 'PATCH',
@ -83,6 +146,15 @@ export async function deleteAdminBlog(id: number): Promise<void> {
}) })
} }
export const deleteAdminArticle = deleteAdminBlog
export async function toggleAdminArticleStatus(id: number, status: boolean): Promise<void> {
await apiFetch(`blogs/admin/${id}/`, {
method: 'PATCH',
body: { status },
})
}
export async function fetchBlogTags(): Promise<BlogTag[]> { export async function fetchBlogTags(): Promise<BlogTag[]> {
const data = await apiFetch<any>('blogs/admin/tags/') const data = await apiFetch<any>('blogs/admin/tags/')
return Array.isArray(data) ? data : data?.results ?? [] return Array.isArray(data) ? data : data?.results ?? []

3
src/stores/theme.ts

@ -44,5 +44,8 @@ export function applyTheme(isDark: boolean) {
root.classList.add('light') root.classList.add('light')
root.classList.remove('dark') root.classList.remove('dark')
} }
try {
sessionStorage.setItem('dashboard-theme', JSON.stringify({ state: { isDark }, version: 0 }))
} catch (e) {}
} }
} }

62
vite.config.ts

@ -128,37 +128,63 @@ function openViaCli(locator: string, callback: (err: Error | null, bin?: string)
function openInIde(locator: string, callback: (err: Error | null, bin?: string) => void) { function openInIde(locator: string, callback: (err: Error | null, bin?: string) => void) {
const isWindows = os.platform() === 'win32' const isWindows = os.platform() === 'win32'
const isMac = os.platform() === 'darwin' const isMac = os.platform() === 'darwin'
const home = os.homedir()
const cleanPath = locator.replace(/^[/\\]+/, '')
const targetUri = `/${cleanPath}`
// Clean and prepare URI
const cleanPath = locator.replace(/\\/g, '/').replace(/^\/+/, '')
const encodedTargetUri = encodeURI(`/${cleanPath}`)
if (isMac) { if (isMac) {
// Specifically target Antigravity IDE.app to prevent opening regular Antigravity or other apps
const ideAppPath = fs.existsSync('/Applications/Antigravity IDE.app')
? '/Applications/Antigravity IDE.app'
: path.join(os.homedir(), 'Applications/Antigravity IDE.app')
// 1. Direct macOS protocol URL scheme with targeted app:
// Uses macOS LaunchServices directly without spawning a secondary Electron CLI process.
// Instant (<100ms) with zero dock bounce and no delay.
const ideAppPaths = [
'/Applications/Antigravity IDE.app',
path.join(home, 'Applications/Antigravity IDE.app'),
'/Applications/Antigravity.app',
path.join(home, 'Applications/Antigravity.app'),
'/Applications/Cursor.app',
path.join(home, 'Applications/Cursor.app'),
'/Applications/Visual Studio Code.app',
path.join(home, 'Applications/Visual Studio Code.app'),
]
const hasIdeApp = fs.existsSync(ideAppPath)
const cmd = hasIdeApp
? `open -a "${ideAppPath}" "antigravity-ide://file${targetUri}"`
: `open "antigravity-ide://file${targetUri}" || open "cursor://file${targetUri}" || open "vscode://file${targetUri}"`
const foundApp = ideAppPaths.find((p) => fs.existsSync(p))
let scheme = 'antigravity-ide'
if (foundApp?.includes('Cursor')) {
scheme = 'cursor'
} else if (foundApp?.includes('Visual Studio Code')) {
scheme = 'vscode'
} else if (foundApp && !foundApp.includes('Antigravity IDE') && foundApp.includes('Antigravity')) {
scheme = 'antigravity'
}
const url = `${scheme}://file${encodedTargetUri}`
const cmd = foundApp ? `open -a "${foundApp}" "${url}"` : `open "${url}"`
exec(cmd, (error) => { exec(cmd, (error) => {
// Specifically bring Antigravity IDE to the foreground (never 'Antigravity')
exec(
`osascript -e 'tell application id "com.google.antigravity-ide" to activate' -e 'tell application "Antigravity IDE" to activate' 2>/dev/null`,
() => {}
)
if (!error) { if (!error) {
return callback(null, 'Antigravity IDE')
if (foundApp?.includes('Antigravity IDE') || !foundApp) {
exec(`osascript -e 'tell application id "com.google.antigravity-ide" to activate' 2>/dev/null`, () => {})
} else if (foundApp.includes('Antigravity')) {
exec(`osascript -e 'tell application id "com.google.antigravity" to activate' 2>/dev/null`, () => {})
} else if (foundApp.includes('Cursor')) {
exec(`osascript -e 'tell application "Cursor" to activate' 2>/dev/null`, () => {})
} else if (foundApp.includes('Visual Studio Code')) {
exec(`osascript -e 'tell application "Visual Studio Code" to activate' 2>/dev/null`, () => {})
}
return callback(null, foundApp || scheme)
} }
// Fallback to CLI if open command failed
openViaCli(locator, callback) openViaCli(locator, callback)
}) })
return return
} }
if (isWindows) { if (isWindows) {
const cmd = `start "" /MAX "antigravity-ide://file${targetUri}" || start "" /MAX "cursor://file${targetUri}" || start "" /MAX "vscode://file${targetUri}"`
const cmd = `start "" /MAX "antigravity-ide://file${encodedTargetUri}" || start "" /MAX "cursor://file${encodedTargetUri}" || start "" /MAX "vscode://file${encodedTargetUri}"`
exec(cmd, (error) => { exec(cmd, (error) => {
if (!error) { if (!error) {
return callback(null, 'antigravity-ide://file') return callback(null, 'antigravity-ide://file')
@ -169,7 +195,7 @@ function openInIde(locator: string, callback: (err: Error | null, bin?: string)
} }
// Linux // Linux
const cmd = `xdg-open "antigravity-ide://file${targetUri}" || xdg-open "vscode://file${targetUri}"`
const cmd = `xdg-open "antigravity-ide://file${encodedTargetUri}" || xdg-open "vscode://file${encodedTargetUri}"`
exec(cmd, (error) => { exec(cmd, (error) => {
if (!error) { if (!error) {
return callback(null, 'antigravity-ide://file') return callback(null, 'antigravity-ide://file')

Loading…
Cancel
Save