feat: polish admin UI, phone login, and product details
Switch admin auth to mobile, restyle shell/login with Vuesax icons, and add product detail fields plus public product pages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Page shell inside the white panel:
|
||||
* - optional sticky head (toolbar / filters)
|
||||
* - body: scrolls by default, or locks height when `lockBody` (table fills + scrolls internally)
|
||||
*/
|
||||
defineProps<{ lockBody?: boolean }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ap">
|
||||
<header v-if="$slots.head" class="ap__head">
|
||||
<slot name="head" />
|
||||
</header>
|
||||
<div class="ap__body" :class="{ 'ap__body--locked': lockBody }">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ap__head {
|
||||
flex-shrink: 0;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.ap__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ap__body--locked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, PictureOutlined } from "@ant-design/icons-vue";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
import type { MediaDto } from "@imen/shared";
|
||||
import { ref } from "vue";
|
||||
import MediaPicker from "./MediaPicker.vue";
|
||||
@@ -17,15 +17,15 @@ const open = ref(false);
|
||||
class="flex size-28 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-dashed border-gray-300 bg-gray-50"
|
||||
>
|
||||
<img v-if="url" :src="url" alt="" class="size-full object-contain" />
|
||||
<PictureOutlined v-else class="text-2xl text-gray-300" />
|
||||
<VuesaxIcon v-else name="gallery" :size="32" class="text-gray-300" />
|
||||
</div>
|
||||
<a-space direction="vertical">
|
||||
<a-button @click="open = true">
|
||||
<template #icon><PictureOutlined /></template>
|
||||
<template #icon><VuesaxIcon name="gallery" :size="16" /></template>
|
||||
{{ url ? "تغییر تصویر" : "انتخاب یا آپلود تصویر" }}
|
||||
</a-button>
|
||||
<a-button v-if="url && clearable" danger @click="emit('clear')">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
|
||||
حذف تصویر
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
/** Textarea where every non-empty line is one list item (for longer items than tags allow) */
|
||||
const items = defineModel<string[]>({ required: true });
|
||||
defineProps<{ placeholder?: string; rows?: number }>();
|
||||
|
||||
const parse = (text: string) =>
|
||||
text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// keep the raw text locally so blank lines and trailing newlines survive while typing
|
||||
const text = ref(items.value.join("\n"));
|
||||
watch(items, (v) => {
|
||||
if (parse(text.value).join("\n") !== v.join("\n")) text.value = v.join("\n");
|
||||
});
|
||||
const onInput = () => {
|
||||
items.value = parse(text.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-textarea
|
||||
v-model:value="text"
|
||||
:placeholder="placeholder"
|
||||
:auto-size="{ minRows: rows ?? 3, maxRows: 14 }"
|
||||
@change="onInput"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
|
||||
/** Editable list: add, remove and reorder items; each item is rendered by the default slot */
|
||||
const items = defineModel<T[]>({ required: true });
|
||||
@@ -41,14 +41,14 @@ const add = () => {
|
||||
</span>
|
||||
<a-space :size="4">
|
||||
<a-button size="small" :disabled="i === 0" title="بالا" @click="move(i, -1)">
|
||||
<template #icon><ArrowUpOutlined /></template>
|
||||
<template #icon><VuesaxIcon name="arrow-up" :size="16" /></template>
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="i === items.length - 1" title="پایین" @click="move(i, 1)">
|
||||
<template #icon><ArrowDownOutlined /></template>
|
||||
<template #icon><VuesaxIcon name="arrow-down" :size="16" /></template>
|
||||
</a-button>
|
||||
<a-popconfirm title="این مورد حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(i)">
|
||||
<a-button size="small" danger title="حذف">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
@@ -56,7 +56,7 @@ const add = () => {
|
||||
<slot :item="item" :index="i" />
|
||||
</div>
|
||||
<a-button type="dashed" block :disabled="items.length >= max" @click="add">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<template #icon><VuesaxIcon name="add" :size="16" /></template>
|
||||
{{ addLabel }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { InboxOutlined } from "@ant-design/icons-vue";
|
||||
import type { MediaDto, Paginated } from "@imen/shared";
|
||||
import { message } from "ant-design-vue";
|
||||
import { ref, watch } from "vue";
|
||||
import { api, errorMessage } from "@/api/http";
|
||||
import VuesaxIcon from "@/components/VuesaxIcon.vue";
|
||||
|
||||
/** Modal to upload a new image or pick one from the media library */
|
||||
const open = defineModel<boolean>("open", { required: true });
|
||||
@@ -102,7 +102,7 @@ async function upload(file: File) {
|
||||
:show-upload-list="false"
|
||||
:before-upload="upload"
|
||||
>
|
||||
<p class="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p class="ant-upload-drag-icon"><VuesaxIcon name="document-upload" :size="40" /></p>
|
||||
<p class="ant-upload-text">تصویر را اینجا رها کنید یا کلیک کنید</p>
|
||||
<p class="ant-upload-hint">
|
||||
JPG، PNG، WebP، AVIF یا GIF — حداکثر ۱۵ مگابایت. تصویر بهصورت خودکار بهینه و به WebP تبدیل میشود.
|
||||
|
||||
@@ -3,16 +3,64 @@ defineProps<{ title: string; subtitle?: string; back?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<RouterLink v-if="back" :to="back" class="text-gray-400 hover:text-gray-700" title="بازگشت">→</RouterLink>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="mt-1 text-sm text-gray-500">{{ subtitle }}</p>
|
||||
<div class="page-header">
|
||||
<div class="page-header__main">
|
||||
<RouterLink v-if="back" :to="back" class="page-header__back" title="بازگشت">→</RouterLink>
|
||||
<div class="min-w-0">
|
||||
<h1 class="page-header__title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div v-if="$slots.default" class="page-header__actions">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-header__back {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.page-header__back:hover {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.page-header__subtitle {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.page-header__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import icons from "@/icons/vuesax-linear.json";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Short name (`add`) or full key (`vuesax-linear:add`) */
|
||||
name: string;
|
||||
size?: number | string;
|
||||
}>(),
|
||||
{ size: 18 },
|
||||
);
|
||||
|
||||
const pack = icons as Record<string, string>;
|
||||
|
||||
const svgHtml = computed(() => {
|
||||
const key = props.name.startsWith("vuesax-linear:")
|
||||
? props.name
|
||||
: `vuesax-linear:${props.name}`;
|
||||
const raw = pack[key];
|
||||
if (!raw) return "";
|
||||
return raw
|
||||
.replace(/\swidth="24"/, "")
|
||||
.replace(/\sheight="24"/, "")
|
||||
.replace(/stroke="#[A-Fa-f0-9]{3,8}"/g, 'stroke="currentColor"')
|
||||
.replace(/fill="(?!none)#[A-Fa-f0-9]{3,8}"/g, 'fill="currentColor"');
|
||||
});
|
||||
|
||||
const box = computed(() =>
|
||||
typeof props.size === "number" ? `${props.size}px` : props.size,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="vx-icon"
|
||||
:style="{ width: box, height: box }"
|
||||
:aria-hidden="true"
|
||||
v-html="svgHtml"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vx-icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.vx-icon :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { h, type VNode } from "vue";
|
||||
import VuesaxIcon from "./VuesaxIcon.vue";
|
||||
|
||||
/** Render a Vuesax linear icon (for Ant menu `icon` factories, etc.) */
|
||||
export const vx = (name: string, size = 18): VNode => h(VuesaxIcon, { name, size });
|
||||
Reference in New Issue
Block a user