Initial commit of Vue Website Template

This commit is contained in:
Flook 2025-07-08 05:38:03 +07:00
commit 85263517ab
64 changed files with 4877 additions and 0 deletions

56
.gitignore vendored Normal file
View File

@ -0,0 +1,56 @@
# ==============================
# ⚙️ Node / Vite
# ==============================
node_modules/
dist/
dist-ssr/
*.local
.env
.env.*.local
# ==============================
# 🧾 Logs
# ==============================
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# ==============================
# 🧠 IDEs
# ==============================
# 👉 IntelliJ / WebStorm
.idea/
*.iml
*.iws
out/
# 👉 VSCode (ถ้าใช้ร่วม)
.vscode/
!.vscode/extensions.json
# ==============================
# 🖼 macOS / OS-specific
# ==============================
.DS_Store
Thumbs.db
# ==============================
# 🐳 Docker (ถ้าใช้)
# ==============================
docker-compose.override.yml
*.pid
# ==============================
# 🧪 Testing (ถ้าใช้)
# ==============================
coverage/
*.tsbuildinfo
*.test.*
*.spec.*
playwright-report/
cypress/videos/
cypress/screenshots/

BIN
Picture1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

BIN
Picture2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

81
README.md Normal file
View File

@ -0,0 +1,81 @@
# 🚀 เทมเพลตเว็บไซต์องค์กร (Organizational Website Template)
> **เว็บไซต์องค์กร**
> เว็บไซต์ข่าวสารและประชาสัมพันธ์ขององค์กร นำเสนอข้อมูลอัปเดตเกี่ยวกับกิจกรรม นวัตกรรม และเรื่องราวดี ๆ ที่เราอยากบอกต่อ ทั้งในภาษาไทยและภาษาอังกฤษ
---
## ✨ คุณสมบัติหลัก
- 🔖 **ข่าวสารตามหมวดหมู่** เช่น ข่าวประชาสัมพันธ์, ข่าวบริการประชาชน, ข่าวนวัตกรรม
- 🌟 **ข่าวเด่น (Feature Story)**: เรื่องราวดี ๆ ที่น่าสนใจและสร้างแรงบันดาลใจ
- 🌐 **รองรับหลายภาษา**: ภาษาไทย และภาษาอังกฤษ
- 📰 **หน้าแสดงข่าวรายละเอียด**: พร้อมรูปภาพและเนื้อหาฉบับเต็ม
- 📱 **Responsive Design**: แสดงผลได้ทุกอุปกรณ์ (มือถือ แท็บเล็ต คอมพิวเตอร์)
- ⚙️ **จัดการสถานะด้วย Pinia Store**
- 📦 **Component-based**: ใช้โครงสร้างที่จัดระเบียบดี พร้อมนำกลับมาใช้ซ้ำ
---
## 🛠️ เทคโนโลยีที่ใช้
| เทคโนโลยี | รายละเอียด |
|-----------|-------------|
| [Vue 3](https://vuejs.org/) | JavaScript Framework |
| [Vite](https://vitejs.dev/) | Frontend Build Tool ที่รวดเร็ว |
| [Pinia](https://pinia.vuejs.org/) | State Management |
| [Vue Router](https://router.vuejs.org/) | Routing สำหรับ SPA |
| [Tailwind CSS](https://tailwindcss.com/) | Utility-first CSS Framework |
| JavaScript (ESNext) | ใช้ Feature ใหม่ของ ECMAScript |
---
## 🚀 วิธีติดตั้งและใช้งาน
### 🧩 ข้อกำหนดเบื้องต้น
- ติดตั้ง [Node.js](https://nodejs.org/) (แนะนำเวอร์ชัน LTS)
- ติดตั้ง npm หรือ yarn
### 📥 ติดตั้งโปรเจกต์
```bash
git clone https://github.com/your-username/your-repo-name.git
cd your-repo-name
npm install
```
### ▶️ เริ่มต้น Development Server
```bash
npm run dev
```
## 📁 โครงสร้างโปรเจกต์
```bash
project/
├── public/ # ไฟล์ static (เช่น logo, favicon)
├── src/
│ ├── assets/ # ไฟล์ภาพ/ไอคอน/ฟอนต์
│ ├── components/ # Vue components เช่น NewsItem, Tabs
│ ├── router/ # การตั้งค่า Routing (Vue Router)
│ ├── stores/ # Pinia stores (จัดการสถานะ)
│ ├── views/ # หน้าแสดงผล เช่น HomeView, NewsView
│ └── main.js # Entry Point ของแอป
├── vite.config.js # การตั้งค่า Vite
├── .gitignore # ไฟล์ที่ Git จะไม่ติดตาม
└── README.md # ไฟล์คำอธิบายโปรเจกต์นี้
```
## 📄 License
> `MIT License` © 2025 softwarecraft.tech
## **การมีส่วนร่วม (Contributing)**
```markdown
## 🤝 การมีส่วนร่วม
ยินดีต้อนรับทุกการมีส่วนร่วม 🎉
หากคุณพบปัญหา, มีข้อเสนอแนะ หรืออยากพัฒนาเพิ่ม
สามารถแจ้งได้ที่ admin@softwarecraft.tech ได้เลยครับ!

BIN
img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HumanTech</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2329
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "vue_web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@tailwindcss/vite": "^4.1.11",
"axios": "^1.10.0",
"daisyui": "^5.0.43",
"lodash": "^4.17.21",
"pinia": "^3.0.3",
"tailwindcss": "^4.1.11",
"vue": "^3.5.17",
"vue-router": "^4.5.1",
"vue3-carousel": "^0.16.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^7.0.0"
}
}

1
public/icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/images/Enter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

BIN
public/uploads/news_1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

BIN
public/uploads/news_2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

BIN
public/uploads/news_3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
public/uploads/news_4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
public/uploads/news_5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

BIN
public/uploads/news_6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

38
src/App.vue Normal file
View File

@ -0,0 +1,38 @@
// App.vue
<template>
<router-view />
</template>
<script setup>
import { onMounted } from 'vue';
onMounted(() => {
// localStorage () 'light'
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
const themeController = document.querySelector('.theme-controller');
if (themeController) {
// toggle
themeController.checked = (savedTheme === themeController.value);
themeController.addEventListener('change', (e) => {
const newTheme = e.target.checked ? e.target.value : 'light'; // 'light' default toggle
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme); // localStorage
});
}
});
</script>
<style>
/* Global CSS
ควรจะยายไปท หรอใน src/assets/main.css
*/
body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
</style>

6
src/assets/main.css Normal file
View File

@ -0,0 +1,6 @@
@import url("https://fonts.googleapis.com/css2?family=Noto%20Sans%20Thai:wght@400;500;600;700;800;900&display=swap");
@import 'vue3-carousel/dist/carousel.css';
body {
font-family: 'Noto Sans Thai', sans-serif;
}

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,57 @@
// src/components/BannerCalousels.vue
<template>
<div class="w-full overflow-hidden p-4">
<!-- วขอดานบน -->
<div class="mb-4 px-2 md:px-4">
<h4
class="inline-block text-white text-2xl md:text-3xl font-semibold px-6 py-2 bg-[#1b3872] rounded shadow-md"
>
{{ appStore.checkLang.isTh ? 'โอกาสทางอาชีพ / แบนเนอร์ประชาสัมพันธ์' : 'Career Opportunities / Public Relations Banners' }}
</h4>
</div>
<!-- Carousel -->
<Carousel
:items-to-show="4"
:wrap-around="true"
:autoplay="5000"
:pause-autoplay-on-hover="true"
:navigation-enabled="false"
:pagination-enabled="false"
class="w-full h-32 md:h-40"
>
<Slide v-for="(image, key) in images" :key="key">
<a
:href="image.link ? image.link : '#'"
target="_blank"
rel="noopener noreferrer"
class="block w-full h-full"
>
<img
:src="`${appStore.imageBaseUrl}${image.image.url}`"
alt="Slider Banner"
class="w-full h-full object-cover rounded-md shadow-sm"
/>
</a>
</Slide>
</Carousel>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { Carousel, Slide } from 'vue3-carousel';
// import 'vue3-carousel/dist/carousel.css'; import (main.js/ts)
import { useAppStore } from '@/stores/app';
const appStore = useAppStore();
const images = ref([]);
onMounted(async () => {
images.value = await appStore.find('slider-banners');
});
</script>
<style scoped>
</style>

View File

@ -0,0 +1,85 @@
// src/components/Banners.vue
<template>
<div class="p-4 grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="col-span-1 md:col-span-1">
<div v-for="(image, key) in feeds" :key="key" class="mb-4">
<a :href="image.link" target="_blank" rel="noopener noreferrer" class="block">
<img
:src="`${appStore.imageBaseUrl}${image.image.url}`"
alt="Feed Banner"
class="w-full object-cover"
:style="{ height: feedsHeightStyle }"
/>
</a>
</div>
</div>
<div class="col-span-1 md:col-span-2">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="(image, key) in fixed" :key="key" class="p-0">
<div v-if="image.link && image.link.startsWith('/')">
<router-link :to="image.link" class="block">
<img
:src="`${appStore.imageBaseUrl}${image.image.url}`"
alt="Fixed Banner"
class="w-full object-cover h-48"
/>
</router-link>
</div>
<div v-else>
<a :href="image.link" target="_blank" rel="noopener noreferrer" class="block">
<img
:src="`${appStore.imageBaseUrl}${image.image.url}`"
alt="Fixed Banner"
class="w-full object-cover h-48"
/>
</a>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useAppStore } from '@/stores/app';
import { RouterLink } from 'vue-router';
const appStore = useAppStore();
const fixed = ref([]);
const feeds = ref([]);
const fixedHeight = ref(188); // in px, as in original code
const feedsHeight = ref(188); // in px
const feedsHeightStyle = computed(() => {
return `${feedsHeight.value}px`;
});
onMounted(async () => {
// feeds (carousel type)
const fetchedFeeds = await appStore.find('bottom-banners', 'banner_type=calousel&_limit=1');
feeds.value = fetchedFeeds;
// fixed banners
const fetchedFixed = await appStore.find('bottom-banners', 'banner_type=fixed&_limit=6');
fixed.value = fetchedFixed;
// feedsHeight fixed banners
const fixedNumber = fixed.value.length / 3; // Original logic
if (fixedNumber <= 1) {
feedsHeight.value = fixedHeight.value;
} else if (fixedNumber > 1 && fixedNumber <= 2) {
feedsHeight.value = fixedHeight.value * 2;
} else if (fixedNumber > 2 && fixedNumber <= 3) {
feedsHeight.value = fixedHeight.value * 3;
} else if (fixedNumber > 3 && fixedNumber <= 4) {
feedsHeight.value = fixedHeight.value * 4;
}
});
</script>
<style scoped>
/* No specific styles needed, using Tailwind classes */
</style>

View File

@ -0,0 +1,135 @@
// src/components/Calousels.vue
<template>
<div class="relative w-full overflow-hidden">
<Carousel
:autoplay="5000"
:wrap-around="true"
:pause-autoplay-on-hover="true"
:navigation-enabled="true"
:pagination-enabled="true"
class="w-full"
>
<Slide v-for="(item, i) in items" :key="i">
<a
v-if="item.type === 'image'"
:href="getImageLink(item)"
:target="item.link || item.File ? '_blank' : '_self'"
rel="noopener noreferrer"
class="block w-full carousel__slide-content"
>
<img
:src="`${appStore.imageBaseUrl}${item.image?.url}`"
alt="Carousel Image"
class="w-full h-auto object-cover max-h-[600px] sm:max-h-[480px] md:max-h-[550px] lg:max-h-[600px] aspect-video"
/>
</a>
<div v-else-if="item.type === 'video' && item.video_url" class="w-full h-full carousel__slide-content">
<iframe
:src="getEmbedUrl(item.video_url)"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="w-full h-full object-cover max-h-[600px] sm:max-h-[480px] md:max-h-[550px] lg:max-h-[600px] aspect-video"
></iframe>
</div>
<div v-else class="w-full h-[300px] bg-gray-200 flex items-center justify-center text-gray-500">
Content not available
</div>
</Slide>
<template #addons>
<Navigation />
<Pagination />
</template>
</Carousel>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'; // watch
import { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel';
import { useAppStore } from '@/stores/app';
const appStore = useAppStore();
const items = ref([]);
const getImageLink = (item) => {
return item.File?.url ? `${appStore.imageBaseUrl}${item.File.url}` : item.link;
};
const getEmbedUrl = (url) => {
if (!url) return '';
const youtubeMatch = url.match(/(?:https?:\/\/)?(?:www\.)?(?:m\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=|embed\/|v\/|)([\w-]{11})(?:\S+)?/);
if (youtubeMatch && youtubeMatch[1]) {
return `https://www.youtube.com/embed/${youtubeMatch[1]}?autoplay=1&mute=1&controls=1&showinfo=0&rel=0`;
}
const vimeoMatch = url.match(/(?:https?:\/\/)?(?:www\.)?(?:player\.)?vimeo\.com\/(?:video\/)?(\d+)(?:\S+)?/);
if (vimeoMatch && vimeoMatch[1]) {
return `https://player.vimeo.com/video/${vimeoMatch[1]}?autoplay=1&muted=1`;
}
return url;
};
// --- New function to fetch items ---
const fetchItems = async () => {
items.value = await appStore.find('calousels');
};
// --- Watch for language changes ---
watch(
() => appStore.checkLang.isTh, // watch
async (newLangStatus, oldLangStatus) => {
//
if (newLangStatus !== oldLangStatus) {
console.log('Language changed! Fetching new carousel items...');
await fetchItems(); // fetchItems
}
},
{ immediate: true } // fetchItems() component mount
);
// onMounted hook fetchItems()
// onMounted(async () => {
// items.value = await appStore.find('calousels');
// });
</script>
<style scoped>
/* Optional: Adjust max-height and ensure aspect ratio for video */
.carousel__slide-content {
display: flex; /* Helps to center content if not full width/height */
align-items: center;
justify-content: center;
height: 100%; /* Ensure content fills the slide height */
overflow: hidden; /* Prevent overflow if aspect-ratio doesn't fit exactly */
}
/* For aspect-ratio if not supported by browser use padding-bottom trick */
.aspect-video {
aspect-ratio: 16 / 9; /* Modern way to set aspect ratio */
}
/* Fallback for older browsers if aspect-ratio is not fully supported */
/*
.video-responsive-container {
position: relative;
width: 100%;
padding-bottom: 56.25%; // 16:9 aspect ratio
height: 0;
overflow: hidden;
}
.video-responsive-container iframe,
.video-responsive-container video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
*/
</style>

54
src/components/Footer.vue Normal file
View File

@ -0,0 +1,54 @@
// src/components/Footer.vue
<template>
<footer class="bg-gray-700 text-white py-4 md:py-8">
<div class="container mx-auto text-center">
<p class="text-sm mb-4">
{{appStore.checkLang.isTh ? more : more_en}}
</p>
<div class="text-xs space-x-2">
<template v-for="(menu, key) in appStore.allFooterMenus" :key="key">
<span v-if="appStore.checkLang.isTh ? menu.active : menu.active_en">
<span v-if="key > 0">|</span> <a
v-if="menu.File && menu.File.url"
:href="`${appStore.imageBaseUrl}${menu.File.url}`"
target="_blank"
class="text-white hover:underline"
>
{{ appStore.checkLang.isTh ? menu.title : menu.title_en }}
</a>
<router-link
v-else-if="menu.link"
:to="menu.link"
class="text-white hover:underline"
>
{{ appStore.checkLang.isTh ? menu.title : menu.title_en }}
</router-link>
<span v-else>
{{ appStore.checkLang.isTh ? menu.title : menu.title_en }}
</span>
</span>
</template>
|<br />
[ HumanTech Call Center: 0-1234-5678 ]
</div>
</div>
</footer>
</template>
<script setup>
import { ref } from 'vue';
import { useAppStore } from '@/stores/app';
import { RouterLink } from 'vue-router'; // Import RouterLink for internal links
const appStore = useAppStore();
const title = ref("ฮิวแมนเทคไทยแลนด์");
const title_en = ref("HumanTech");
const more = ref("ดำเนินการโดย ฮิวแมนเทคไทยแลนด์ แขวงสายไหม เขตสายไหม กรุงเทพฯ 10220 สงวนลิขสิทธิ พ.ศ.2564 ตามพระราชบัญญัติลิขสิทธิ์ พ.ศ.2537");
const more_en = ref("This website is administered by the HumanTech, Sai Mai, Bangkok 10220 Copyright ©2025 HumanTech all rights reserved.");
</script>
<style scoped>
/* สไตล์เฉพาะ Footer หากไม่ถูกครอบคลุมโดย Tailwind */
</style>

178
src/components/Header.vue Normal file
View File

@ -0,0 +1,178 @@
// src/components/Header.vue
<template>
<header
v-if="appStore.headerData"
:style="`background-image: url('${appStore.imageBaseUrl}${appStore.headerData.header_background.url}'); background-size: cover; background-position: center;`"
class="relative z-10"
>
<nav class="navbar p-2 md:p-4 shadow-md" :style="{ backgroundColor: appStore.headerData.mainColor }">
<div class="container mx-auto flex items-center justify-between w-full">
<div class="navbar-start w-auto flex-shrink-0">
<router-link to="/home" class="flex flex-col items-start leading-tight">
<span class="text-white text-lg font-bold">{{ appStore.checkLang.isTh ? 'ฮิวแมนเทคไทยแลนด์' : 'HumanTech' }}</span>
<span class="text-white text-xs opacity-80">{{ appStore.checkLang.isTh ? 'HumanTech' : 'ฮิวแมนเทคไทยแลนด์' }}</span>
</router-link>
</div>
<div class="navbar-end hidden md:flex flex-1 items-center justify-end space-x-2 lg:space-x-4">
<ul class="menu menu-horizontal px-1 flex-nowrap">
<li v-for="menu in orderedMainMenus" :key="menu.id">
<template v-if="menu.sub_menus && menu.sub_menus.length > 0">
<details :open="activeMenuId === menu.id" @toggle="handleDetailsToggle(menu.id, $event)">
<summary class="text-white font-semibold text-base hover:bg-gray-700 hover:text-white">
{{ appStore.checkLang.isTh ? menu.main_menu : menu.main_menu_en }}
</summary>
<ul class="p-2 bg-base-100 rounded-box w-52 text-black">
<li v-for="subMenu in (appStore.checkLang.isTh ? orderedSubMenus(menu.sub_menus) : orderedSubMenusEng(menu.sub_menus))" :key="subMenu.id">
<router-link :to="subMenu.link" class="hover:bg-gray-200 hover:text-black">
{{ appStore.checkLang.isTh ? subMenu.title_th : subMenu.title_en }}
</router-link>
</li>
</ul>
</details>
</template>
<template v-else>
<router-link
:to="menu.link"
class="text-white font-semibold text-base hover:bg-gray-700 hover:text-white"
@click="activeMenuId = null" >
{{ appStore.checkLang.isTh ? menu.main_menu : menu.main_menu_en }}
</router-link>
</template>
</li>
</ul>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost normal-case text-base text-white hover:bg-gray-700 hover:text-white">
{{ appStore.checkLang.isTh ? 'ภาษาไทย' : 'English' }}
</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-30 text-black">
<li><a @click="switchLanguageToThai" class="hover:bg-gray-200 hover:text-black">ภาษาไทย</a></li>
<li><a @click="switchLanguageToEnglish" class="hover:bg-gray-200 hover:text-black">English</a></li>
</ul>
</div>
</div>
<div class="navbar-end md:hidden">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden text-white hover:bg-gray-700 hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52 text-black">
<li v-for="menu in orderedMainMenus" :key="menu.id">
<template v-if="menu.sub_menus && menu.sub_menus.length > 0">
<details :open="activeMobileMenuId === menu.id" @toggle="handleMobileDetailsToggle(menu.id, $event)">
<summary class="hover:bg-gray-200 hover:text-black">{{ appStore.checkLang.isTh ? menu.main_menu : menu.main_menu_en }}</summary>
<ul class="p-2 bg-base-200">
<li v-for="subMenu in (appStore.checkLang.isTh ? orderedSubMenus(menu.sub_menus) : orderedSubMenusEng(menu.sub_menus))" :key="subMenu.id">
<router-link :to="subMenu.link" class="hover:bg-gray-200 hover:text-black">{{ appStore.checkLang.isTh ? subMenu.title_th : subMenu.title_en }}</router-link>
</li>
</ul>
</details>
</template>
<template v-else>
<router-link :to="menu.link" class="hover:bg-gray-200 hover:text-black">
{{ appStore.checkLang.isTh ? menu.main_menu : menu.main_menu_en }}
</router-link>
</template>
</li>
<li>
<details :open="activeMobileMenuId === 'language'" @toggle="handleMobileDetailsToggle('language', $event)">
<summary class="hover:bg-gray-200 hover:text-black">{{ appStore.checkLang.isTh ? 'ภาษา' : 'Language' }}</summary>
<ul class="p-2 bg-base-200">
<li><a @click="switchLanguageToThai" class="hover:bg-gray-200 hover:text-black">ภาษาไทย</a></li>
<li><a @click="switchLanguageToEnglish" class="hover:bg-gray-200 hover:text-black">English</a></li>
</ul>
</details>
</li>
</ul>
</div>
</div>
</div>
</nav>
</header>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useAppStore } from '@/stores/app';
import { RouterLink } from 'vue-router';
import _ from 'lodash';
const appStore = useAppStore();
// --- State to manage open menus ---
const activeMenuId = ref(null); // For desktop dropdowns
const activeMobileMenuId = ref(null); // For mobile dropdowns
// --- Handlers for desktop details toggles ---
const handleDetailsToggle = (menuId, event) => {
if (event.target.open) {
// If this details is being opened, set it as the active one
activeMenuId.value = menuId;
} else if (activeMenuId.value === menuId) {
// If this details is being closed, clear the active one
activeMenuId.value = null;
}
};
// --- Handlers for mobile details toggles ---
const handleMobileDetailsToggle = (menuId, event) => {
if (event.target.open) {
activeMobileMenuId.value = menuId;
} else if (activeMobileMenuId.value === menuId) {
activeMobileMenuId.value = null;
}
};
// --- Existing logic for menu ordering and language switching ---
const orderedMainMenus = computed(() => {
if (!appStore.allMainMenus) return [];
const activeMenus = appStore.allMainMenus.filter(menu =>
appStore.checkLang.isTh ? menu.active : menu.active_en
);
const homeMenu = activeMenus.find(menu => menu.id === 1);
const otherMenus = activeMenus.filter(menu => menu.id !== 1);
return homeMenu ? [homeMenu, ..._.orderBy(otherMenus, 'order')] : _.orderBy(activeMenus, 'order');
});
const orderedSubMenus = (subMenus) => {
if (!subMenus) return [];
const activeItems = subMenus.filter(f => f.active === true);
return _.orderBy(activeItems, 'order');
};
const orderedSubMenusEng = (subMenus) => {
if (!subMenus) return [];
const activeItems = subMenus.filter(f => f.active_en === true);
return _.orderBy(activeItems, 'order');
};
const switchLanguageToThai = () => {
appStore.toggleLanguage();
// Close mobile language dropdown after selection
activeMobileMenuId.value = null;
};
const switchLanguageToEnglish = () => {
appStore.toggleLanguage();
// Close mobile language dropdown after selection
activeMobileMenuId.value = null;
};
onMounted(() => {
const savedLang = localStorage.getItem('lang');
if (savedLang === 'en') {
appStore.isTh = false;
} else {
appStore.isTh = true;
}
});
</script>
<style scoped>
/* No specific styles needed, using Tailwind/DaisyUI as before */
</style>

View File

@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

103
src/components/News.vue Normal file
View File

@ -0,0 +1,103 @@
// src/components/News.vue
<template>
<div class="mt-6">
<div
class="p-4 bg-cover bg-center"
:style="`background-image: url('${appStore.imageBaseUrl}${appStore.headers.header_background.url}')`"
>
<h4
class="inline-block text-white text-2xl md:text-3xl font-semibold px-6 py-2 bg-[#1b3872] rounded shadow-md"
>
{{ appStore.checkLang.isTh ? 'เรื่องราวดี ๆ ที่เราอยากบอกต่อ' : 'Our Inspiring Stories' }}
</h4>
</div>
<div class="p-6" :style="`background-color:${appStore.headers.bgColor || '#ffffff'}`">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 ">
<div v-if="filteredFeatureNews.length > 0" class="flex flex-col">
<NewsItem :item="filteredFeatureNews[0]" :isFeature="true" />
</div>
<div v-else class="h-full">
<div class="bg-gray-200 flex items-center justify-center rounded-lg text-gray-500 h-full">
{{ appStore.checkLang.isTh ? 'ไม่พบข่าวเด่น' : 'No feature news found' }}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="(item, key) in filteredNews"
:key="key"
class="flex flex-col"
>
<NewsItem :item="item" />
</div>
</div>
<div class="md:col-span-2 flex justify-end mt-8">
<router-link
to="/news/HumanTechNews" class="btn bg-[#1b3872] text-white hover:bg-[#1b3872]/90 rounded-none border-none shadow-md"
>
{{ appStore.checkLang.isTh ? more : more_en }}
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
// import Carousel, Slide
import NewsItem from './NewsItem.vue'; // Component
import { useAppStore } from '@/stores/app';
import { RouterLink } from 'vue-router'; // Import RouterLink
const appStore = useAppStore();
const news = ref([]);
const featureNews = ref([]); // filter
const more = "อ่านข่าวต่อ";
const more_en = "Read More";
const filterNewsByDate = (items) => {
const today = new Date();
const currentDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()); // Normalize to start of day
return items.filter(item => {
if (!item.release_date) return false;
const [month, day, year] = item.release_date.split('/');
const releaseDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
return releaseDate <= currentDate;
});
};
const filteredFeatureNews = computed(() => {
const langFiltered = appStore.checkLang.isTh
? featureNews.value.filter(item => item.active && item.feature)
: featureNews.value.filter(item => item.active_en && item.feature);
// **:** 1
return filterNewsByDate(langFiltered).slice(0, 1);
});
const filteredNews = computed(() => {
const langFiltered = appStore.checkLang.isTh
? news.value.filter(item => item.active && !item.feature)
: news.value.filter(item => item.active_en && !item.feature);
// Limit to 4 items as per original logic
return filterNewsByDate(langFiltered).slice(0, 4);
});
onMounted(async () => {
const allNews = await appStore.find('news', '');
news.value = allNews;
featureNews.value = allNews; // featureNews allNews
});
</script>
<style scoped>
</style>

View File

@ -0,0 +1,86 @@
// src/components/NewsItem.vue
<template>
<div class="bg-base-100 shadow-md rounded-lg overflow-hidden flex flex-col h-full hover:shadow-lg hover:-translate-y-1">
<div
class="w-full bg-gray-200 relative "
:class="{
'h-[266px]': isFeature, // : (516px (total) - padding-top/bottom - content_height) / image_ratio
'h-[120px]': !isFeature // : (250px (total) - padding-top/bottom - content_height) / image_ratio
}"
>
<img
:src="`${appStore.imageBaseUrl}${item.image?.url ?? ''}`"
alt="News Thumbnail"
class="absolute inset-0 w-full h-full object-cover"
@error="e => e.target.style.display = 'none'"
/>
</div>
<div class="p-4 flex flex-col flex-grow">
<router-link
:to="`/news-content/${item.id}`"
class="font-semibold text-gray-800 hover:underline mb-2"
:class="{
'text-xl md:text-2xl line-clamp-2': isFeature,
'text-lg line-clamp-2': !isFeature
}"
>
{{ appStore.checkLang.isTh ? item.title_th : item.title_en || 'No Title' }}
</router-link>
<p
class="text-sm text-gray-600 mb-3 flex-grow"
:class="{
'line-clamp-4': isFeature,
'line-clamp-3': !isFeature
}"
>
{{ truncateDetail(appStore.checkLang.isTh ? item.detail_th : item.detail_en || '', isFeature ? 200 : 120) }}
</p>
<div class="text-xs text-gray-400 mt-auto">
<span v-if="item.release_date">{{ formatDate(item.release_date) }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
import { useAppStore } from '@/stores/app';
import { RouterLink } from 'vue-router';
const appStore = useAppStore();
const props = defineProps({
item: {
type: Object,
required: true,
},
isFeature: {
type: Boolean,
default: false,
},
});
const truncateDetail = (text, maxLength) => {
if (!text) return '';
return text.length <= maxLength ? text : text.substring(0, maxLength) + '...';
};
const formatDate = (dateString) => {
if (!dateString) return '';
const parts = dateString.split('/');
if (parts.length === 3) {
const [month, day, year] = parts;
const dateObj = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
const formattedDay = String(dateObj.getDate()).padStart(2, '0');
const formattedMonth = String(dateObj.getMonth() + 1).padStart(2, '0');
const formattedYear = dateObj.getFullYear();
return `${formattedDay}/${formattedMonth}/${formattedYear}`;
}
return dateString;
};
</script>
<style scoped>
/* No specific styles needed, using Tailwind classes */
</style>

158
src/components/TabNews.vue Normal file
View File

@ -0,0 +1,158 @@
// src/components/TabNews.vue
<template>
<div class="p-4">
<!-- วขอขาว -->
<div class="mb-6 px-2 md:px-4">
<h4
class="inline-block text-white text-2xl md:text-3xl font-semibold px-6 py-2 bg-[#1b3872] rounded shadow-md"
>
{{ appStore.checkLang.isTh ? 'ข่าวสารอัพเดต' : 'Latest Updates' }}
</h4>
</div>
<!-- Tabs -->
<div role="tablist" class="flex flex-wrap space-x-2 border-b border-gray-300">
<button
v-for="(tab, index) in tabs"
:key="tab.category"
class="px-4 py-2 text-sm font-medium transition-all duration-200 border-b-2 whitespace-nowrap"
:class="{
'border-blue-700 text-blue-700': activeTab === tab.category,
'border-transparent text-gray-500 hover:text-blue-700 hover:border-blue-500': activeTab !== tab.category
}"
@click="changeTab(tab.category)"
>
{{ appStore.checkLang.isTh ? tab.title : tab.title_en }}
</button>
</div>
<!-- Tab Content -->
<div class="mt-4">
<div
v-for="tab in tabs"
:key="tab.category"
v-show="activeTab === tab.category"
>
<div v-if="newsByTab[tab.category]?.data?.length > 0">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
v-for="(item, index) in newsByTab[tab.category].data"
:key="item.id"
class="card card-compact bg-base-100 shadow-md rounded-md overflow-hidden
transition-transform duration-200 hover:shadow-lg hover:-translate-y-1
flex flex-col md:flex-row"
>
<div class="crop w-full md:w-36 h-36 md:h-auto flex-shrink-0">
<img
:src="`${appStore.imageBaseUrl}${item.image?.url ?? ''}`"
alt="News Thumbnail"
class="w-full h-full object-cover"
@error="e => e.target.style.display = 'none'"
/>
</div>
<div class="card-body p-4 flex flex-col justify-between flex-1">
<router-link
:to="`/content/${item.id}`"
class="card-title text-base text-gray-800 hover:underline line-clamp-2 mb-2"
>
{{ appStore.checkLang.isTh ? item.title_th : item.title_en || 'No Title' }}
</router-link>
<p class="text-sm text-gray-600 line-clamp-2 flex-grow">
{{ truncateDetail(appStore.checkLang.isTh ? item.detail_th : item.detail_en || '', 80) }}
</p>
<div class="text-xs text-gray-400 mt-2">
<span v-if="item.release_date">{{ formatDate(item.release_date) }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center text-gray-500 py-8">
{{ appStore.checkLang.isTh ? 'ไม่พบข่าวสารในหมวดหมู่นี้' : 'No news found in this category' }}
</div>
<div class="flex justify-end mt-6">
<router-link
v-if="newsByTab[tab.category]?.total > 6"
:to="`/news/${tab.category}`"
class="btn bg-[#1b3872] text-white hover:bg-[#1b3872]/90 rounded-none border-none shadow-md"
>
{{ appStore.checkLang.isTh ? more : more_en }}
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useAppStore } from '@/stores/app';
import { RouterLink } from 'vue-router';
const appStore = useAppStore();
const tabs = ref([
{ title: "ข่าวประชาสัมพันธ์หน่วยงาน", title_en: "RTAF News", category: "HumanTechNews" },
{ title: "ข่าวประชาสัมพันธ์ภายใน", title_en: "RTAF Organization News", category: "OrgNews" },
{ title: "ข่าวนวัตกรรม", title_en: "RTAF Service News", category: "InnovationNews" },
{ title: "Press Release", title_en: "RTAF Press Release", category: "GeneralPublic" },
{ title: "ข่าวบริการประชาชน", title_en: "Public Service News", category: "EventActivities" },
]);
const activeTab = ref(tabs.value[0].category);
const newsByTab = ref({});
const more = "อ่านข่าวต่อ";
const more_en = "Read More";
const truncateDetail = (text, maxLength) => {
if (!text) return '';
return text.length <= maxLength ? text : text.substring(0, maxLength) + '...';
};
const formatDate = (dateString) => {
if (!dateString) return '';
const parts = dateString.split('/');
if (parts.length === 3) {
const [month, day, year] = parts;
const dateObj = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
const formattedDay = String(dateObj.getDate()).padStart(2, '0');
const formattedMonth = String(dateObj.getMonth() + 1).padStart(2, '0');
const formattedYear = dateObj.getFullYear();
return `${formattedDay}/${formattedMonth}/${formattedYear}`;
}
return dateString;
};
const fetchNewsForTab = async (category) => {
try {
const { data, total } = await appStore.fetchTabNews(category, 6);
newsByTab.value[category] = { data, total };
} catch (error) {
console.error(`Error fetching news for ${category}:`, error);
newsByTab.value[category] = { data: [], total: 0 };
}
};
const changeTab = async (category) => {
activeTab.value = category;
if (!newsByTab.value[category]) {
await fetchNewsForTab(category);
}
};
onMounted(async () => {
for (const tab of tabs.value) {
await fetchNewsForTab(tab.category);
}
});
</script>
<style scoped>
.crop {
overflow: hidden;
background-color: #f0f0f0;
}
</style>

View File

@ -0,0 +1,39 @@
// src/components/WebLinks.vue
<template>
<div class="mt-6 mb-6 p-4">
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div
class="text-center p-2 border border-blue-400 bg-blue-200 hover:bg-blue-300 transition-colors duration-200 rounded-md"
v-for="(image, key) in images"
:key="key"
>
<a :href="image.link" target="_blank" rel="noopener noreferrer" class="block">
<img
:src="`${appStore.imageBaseUrl}${image.image[0].url}`"
alt="Web Link Icon"
class="mx-auto w-16 h-16 object-contain mb-2"
/>
<p class="text-sm font-medium text-gray-800 leading-tight">
{{ appStore.checkLang.isTh ? image.title : image.title_en }}
</p>
</a>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useAppStore } from '@/stores/app';
const appStore = useAppStore();
const images = ref([]);
onMounted(async () => {
images.value = await appStore.find('bottom-links');
});
</script>
<style scoped>
/* No specific styles needed, using Tailwind classes */
</style>

View File

@ -0,0 +1,25 @@
// src/layouts/DefaultLayout.vue
<template>
<div class="min-h-screen flex flex-col">
<Header /> <main class="flex-grow">
<router-view />
</main>
<Footer />
</div>
</template>
<script setup>
import Header from '@/components/Header.vue'; // Import Header Component
import Footer from '@/components/Footer.vue'; // Import Footer Component
const imageBaseUrl = '';
</script>
<style>
/* สไตล์พื้นหลังเฉพาะตัวของ layout นี้ */
body {
background-color: rgba(255, 255, 255, 0.8); /* สีพื้นหลัง default สำหรับ DefaultLayout */
}
</style>

View File

@ -0,0 +1,36 @@
// src/views/LandingLayout.vue
<template>
<div class="landing-layout">
<main>
<router-view></router-view>
</main>
</div>
</template>
<script setup>
// Background Image Logic Layout
</script>
<style scoped>
/* สไตล์สำหรับ Landing Layout โดยรวม เช่น background */
.landing-layout {
min-height: 100vh; /* ทำให้ Layout สูงเต็มหน้าจอ */
display: flex;
flex-direction: column;
justify-content: center; /* จัดเนื้อหาตรงกลางแนวตั้ง */
align-items: center; /* จัดเนื้อหาตรงกลางแนวนอน */
/* ถ้าต้องการ Background Image สำหรับ Layout ทั้งหมด */
background-image: url('/images/moroccan-flower.png');
/* background-size: cover; */
/* background-position: center; */
}
main {
flex-grow: 1; /* ทำให้ main ขยายเต็มพื้นที่ที่เหลือ */
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
</style>

16
src/main.js Normal file
View File

@ -0,0 +1,16 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import './assets/main.css';
import { createPinia } from 'pinia';
import router from "./router/index.js";
import '@fortawesome/fontawesome-free/css/all.min.css'; // หรือ all.css ถ้าต้องการ debug
const app = createApp(App);
const pinia = createPinia();
app.use(pinia); // ใช้ Pinia
app.use(router); // ใช้ router
app.mount('#app');

73
src/router/index.js Normal file
View File

@ -0,0 +1,73 @@
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
// 1. Import Layouts
import DefaultLayout from "@/layouts/DefaultLayout.vue";
import LandingLayout from "@/layouts/LandingLayout.vue";
// 2. Import Views (สำคัญ: ต้อง import ชื่อที่ถูกต้อง)
import LandingPageView from "@/views/LandingPageView.vue"; // View สำหรับหน้า Landing Page
import HomeView from "@/views/HomeView.vue"; // View สำหรับหน้า Home หลัก (ที่มี Calousels, News ฯลฯ)
import ContentView from "@/views/ContentView.vue"; // View สำหรับแสดงรายละเอียดข่าว
import NewsCategoryView from "@/views/NewsCategoryView.vue"; // View สำหรับแสดงรายการข่าวตามหมวดหมู่
import NotFoundView from '@/views/NotFoundView.vue';
const routes = [
// --- Route สำหรับหน้า Landing Page (ใช้ LandingLayout) ---
{
path: '/',
component: LandingLayout, // LandingLayout คือกรอบของหน้า Landing
children: [
{
path: '', // นี่คือ path สำหรับเนื้อหาที่แสดงเมื่อเข้าถึง '/'
name: 'landing',
component: LandingPageView, // ชี้ไปที่ View ที่มีเนื้อหาเฉพาะ Landing
},
],
},
// --- Route สำหรับหน้า Home และหน้าอื่นๆ ที่ใช้ DefaultLayout ---
{
path: '/',
component: DefaultLayout, // DefaultLayout คือกรอบของหน้า Home หลัก
children: [
{
path: 'home', // HomeView จะถูกโหลดเมื่อเข้าถึง /home
name: 'home',
component: HomeView, // HomeView ที่มี Calousels, News, ฯลฯ
},
// !!! เพิ่ม Route สำหรับ ContentView !!!
{
path: 'news-content/:id', // Path สำหรับรายละเอียดข่าว เช่น /news-content/1, /news-content/2
name: 'ContentView',
component: ContentView,
props: true // ส่งค่า parameter (id) เป็น props ไปยัง component ได้
},
// !!! เพิ่ม Route สำหรับ NewsCategoryView !!!
{
path: 'news/:category', // Path สำหรับข่าวตามหมวดหมู่ เช่น /news/RTAFNews
name: 'NewsCategoryView',
component: NewsCategoryView,
props: true // ส่งค่า parameter (category) เป็น props ไปยัง component ได้
},
// ... ( routes อื่นๆ ที่อาจจะใช้ DefaultLayout )
// *** เส้นทาง 404 (ต้องอยู่สุดท้ายเสมอ!) ***
{
path: '/:pathMatch(.*)*', // นี้คือ catch-all route ที่จะจับ URL ที่ไม่ตรงกับข้างบนทั้งหมด
name: 'NotFound',
component: NotFoundView
}
],
},
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
// อย่าลืม export default router; ที่ท้ายไฟล์
export default router;

672
src/stores/app.js Normal file
View File

@ -0,0 +1,672 @@
// src/stores/app.js
import { defineStore } from 'pinia';
const IMAGE_BASE_URL = '';
export const useAppStore = defineStore('app', {
state: () => ({
isTh: true,
tabs: [ // ตรวจสอบว่า tabs array ใน store ตรงกับ TabNews.vue
{ title: "ข่าวประชาสัมพันธ์หน่วยงาน", title_en: "RTAF News", category: "HumanTechNews" },
{ title: "ข่าวประชาสัมพันธ์ภายใน", title_en: "RTAF Organization News", category: "OrgNews" },
{ title: "ข่าวนวัตกรรม", title_en: "RTAF Service News", category: "InnovationNews" },
{ title: "Press Release", title_en: "RTAF Press Release", category: "GeneralPublic" },
{ title: "ข่าวบริการประชาชน", title_en: "Public Service News", category: "EventActivities" },
],
// *** ข้อมูลสำหรับ Header ***
headers: {
header_background: { url: '/images/news_header_bg_b815923058.png' },
logo: { url: '/images/Enter.png' }, // ใช้รูปโลโก้ตามที่ระบุ
mainColor: '#0A2647', // สีหลักของ Header/Navbar
main_menus: [
{ id: 1, main_menu: 'หน้าหลัก', main_menu_en: 'Home', link: '/home', order: 1, active: true, active_en: true, sub_menus: [] },
{
id: 2, main_menu: 'รู้จักหน่วยงาน', main_menu_en: 'About HumanTech', order: 2, active: true, active_en: true,
sub_menus: [
{ id: 21, title_th: 'ประวัติความเป็นมา', title_en: 'HumanTech History', link: '/about/history', order: 1, active: true, active_en: true },
{ id: 22, title_th: 'วิสัยทัศน์ พันธกิจ', title_en: 'Vision & Mission', link: '/about/vision-mission', order: 2, active: true, active_en: true },
{ id: 23, title_th: 'คณะผู้บริหารองค์กร', title_en: 'Commander-in-Chief', link: '/about/commander', order: 3, active: true, active_en: true },
{ id: 24, title_th: 'นโยบายองค์กร', title_en: 'C-in-C Policy', link: '/about/policy', order: 4, active: true, active_en: true },
{ id: 25, title_th: 'โครงสร้างองค์กร', title_en: 'Organization Structure', link: '/about/organization', order: 5, active: true, active_en: true },
{ id: 26, title_th: 'ทำเนียบผู้บริหารองค์กร', title_en: 'Commanders List', link: '/about/commanders-list', order: 6, active: true, active_en: true },
{ id: 27, title_th: 'ผู้บริหารเทคโนโลยีสารสนเทศระดับสูงองค์กร', title_en: 'HumanTech Senior IT Management', link: '/about/it-management', order: 7, active: true, active_en: true },
]
},
{
id: 3, main_menu: 'ข้อมูลเผยแพร่', main_menu_en: 'Information', order: 3, active: true, active_en: true,
sub_menus: [
{ id: 31, title_th: 'เอกสารเผยแพร่', title_en: 'Publications', link: '/data/publications', order: 1, active: true, active_en: true },
{ id: 32, title_th: 'วารสารองค์กร', title_en: 'HumanTech Journal', link: '/data/journal', order: 2, active: true, active_en: true },
{ id: 33, title_th: 'คลังภาพ/สื่อผสม', title_en: 'Gallery/Media', link: '/data/gallery-media', order: 3, active: true, active_en: true },
{ id: 34, title_th: 'รายงานผลการดำเนินงาน', title_en: 'Performance Report', link: '/data/performance-report', order: 4, active: true, active_en: true },
{ id: 35, title_th: 'รายงานการเงินหน่วยงาน', title_en: 'HumanTech Financial Report', link: '/data/financial-report', order: 5, active: true, active_en: true },
{ id: 36, title_th: 'กฎหมายที่เกี่ยวข้อง', title_en: 'Related Laws', link: '/data/laws', order: 6, active: true, active_en: true },
{ id: 37, title_th: 'การเปิดเผยข้อมูลสาธารณะ', title_en: 'Public Information Disclosure', link: '/data/public-disclosure', order: 7, active: true, active_en: true },
{ id: 38, title_th: 'ปฏิทินกิจกรรม', title_en: 'Activity Calendar', link: '/data/calendar', order: 8, active: true, active_en: true },
]
},
{ id: 4, main_menu: 'บริการของเรา', main_menu_en: 'HumanTech Services', link: '/services', order: 5, active: true, active_en: true, sub_menus: [] },
{
id: 5, main_menu: 'การติดต่อ', main_menu_en: 'Contact', order: 6, active: true, active_en: true,
sub_menus: [
{ id: 51, title_th: 'ติดต่อเรา', title_en: 'Contact Us', link: '/contact/us', order: 1, active: true, active_en: true },
{ id: 52, title_th: 'คำถามยอดฮิต', title_en: 'FAQ', link: '/contact/faq', order: 2, active: true, active_en: true },
{ id: 53, title_th: 'ร้องทุกข์ร้องเรียน', title_en: 'Complaints', link: '/contact/complaints', order: 3, active: true, active_en: true },
]
},
],
},
// *** ข้อมูลสำหรับ Footer ***
footer_menus: {
menu: [
{ id: 1, title: 'ติดต่อเรา', title_en: 'Contact Us', link: '/contact', File: null, active: true, active_en: true },
{ id: 2, title: 'แผนผังเว็บไซต์', title_en: 'Sitemap', link: '/sitemap', File: null, active: true, active_en: true },
// เพิ่มเมนู Footer อื่นๆ
{ id: 3, title: 'เอกสารดาวน์โหลด', title_en: 'Downloads', link: null, File: { url: '/docs/document.pdf' }, active: true, active_en: true },
]
},
// Mock Data สำหรับ Calousels
calousels: [
{
id: 1, // รูปภาพปกติ, มีลิงก์ภายนอก
type: 'image', // ระบุประเภทเป็น 'image'
image: { url: '/uploads/main_banner_1.jpg' }, // URL รูปภาพ
link: 'https://www.google.com', // ลิงก์ภายนอก
File: null, // ไม่มีไฟล์ให้ดาวน์โหลด
video_url: null, // ไม่มีวิดีโอ URL
active: true,
active_en: true,
order: 1,
startDate: '01/01/2025',
endDate: '12/31/2025',
},
{
id: 2, // รูปภาพ, มีไฟล์ให้ดาวน์โหลด
type: 'image',
image: { url: '/uploads/main_banner_2.jpg' },
link: null, // ไม่มีลิงก์ภายนอก
File: { url: '/uploads/document_2.pdf', name: 'document_2.pdf' }, // มีไฟล์ PDF ให้ดาวน์โหลด
video_url: null,
active: true,
active_en: true,
order: 2,
startDate: '01/01/2025',
endDate: '12/31/2025',
},
{
id: 3, // วิดีโอ YouTube
type: 'video', // ระบุประเภทเป็น 'video'
image: { url: '/uploads/youtube-thumbnail-3.jpg' }, // Thumbnail สำหรับวิดีโอ (ถ้ามี)
link: null,
File: null,
video_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', // ตัวอย่าง YouTube URL (Rick Astley)
active: true,
active_en: true,
order: 3,
startDate: '01/01/2025',
endDate: '12/31/2025',
},
{
id: 4, // วิดีโอ Vimeo
type: 'video',
image: { url: '/uploads/vimeo-thumbnail-4.jpg' }, // Thumbnail สำหรับวิดีโอ (ถ้ามี)
link: null,
File: null,
video_url: 'https://vimeo.com/76979871', // ตัวอย่าง Vimeo URL
active: true,
active_en: true,
order: 4,
startDate: '01/01/2025',
endDate: '12/31/2025',
},
{
id: 5, // รูปภาพปกติ, ไม่มีลิงก์/ไฟล์
type: 'image',
image: { url: '/uploads/main_banner_3.jpg' },
link: null,
File: null,
video_url: null,
active: true,
active_en: true,
order: 5,
startDate: '01/01/2025',
endDate: '12/31/2025',
},
{
id: 6, // รูปภาพ, ไม่มีลิงก์/ไฟล์, และ active เป็น false (ไม่ควรแสดง)
type: 'image',
image: { url: '/uploads/main_banner_4.jpg' },
link: null,
File: null,
video_url: null,
active: false, // จะไม่แสดงในภาษาไทย
active_en: true,
order: 6,
startDate: '01/01/2025',
endDate: '12/31/2025',
},
{
id: 7, // วิดีโอ YouTube ที่ไม่ Active ในภาษาไทย
type: 'video',
image: { url: '/uploads/youtube-thumbnail-7.jpg' },
link: null,
File: null,
video_url: 'https://www.youtube.com/embed/n0NcsaOxMhM?si=6guI-XduDl87qMVC',
active: false, // จะไม่แสดงในภาษาไทย
active_en: true,
order: 7,
startDate: '01/01/2025',
endDate: '12/31/2025',
},
],
// Mock Data สำหรับ News
mockNews: [
{
id: 1,
title_th: 'โครงการบินเพื่อชีวิต: กองทัพอากาศช่วยเหลือผู้ป่วยฉุกเฉิน',
title_en: 'Flying for Life: RTAF Assists Emergency Patients',
detail_th: 'กองทัพอากาศได้ปฏิบัติภารกิจบินลำเลียงผู้ป่วยฉุกเฉินจากจังหวัดชายแดนสู่โรงพยาบาลกลางอย่างเร่งด่วน ภารกิจนี้ไม่เพียงแต่ช่วยชีวิต แต่ยังแสดงถึงบทบาทสำคัญของกองทัพในงานด้านมนุษยธรรม',
detail_en: 'The Royal Thai Air Force conducted an urgent airlift mission, transporting critical patients from border provinces to central hospitals, showcasing the humanitarian role of the military.',
image: { url: '/uploads/airlift_patient.jpg' },
release_date: '07/04/2025',
active: true,
active_en: true,
feature: true,
type: 'HumanTechNews'
},
{
id: 2, title_th: 'ข่าวทั่วไป 1 (ไทย)', title_en: 'General News 1 (Eng)',
detail_th: 'รายละเอียดข่าวทั่วไป 1 ภาษาไทย...', detail_en: 'Detail of general news 1 in English...',
image: { url: '/uploads/news_2.jpg' }, release_date: '02/01/2025', active: true, active_en: true, feature: false, type: 'HumanTechNews'
},
{
id: 3, title_th: 'ข่าวเด่น 2 (ไทย)', title_en: 'Hot News 2 (Eng)',
detail_th: 'รายละเอียดข่าวเด่น 2 ภาษาไทย...', detail_en: 'Detail of hot news 2 in English...',
image: { url: '/uploads/news_3.jpg' }, release_date: '03/01/2025', active: true, active_en: true, feature: false, type: 'OrgNews'
},
{
id: 4, title_th: 'ข่าวทั่วไป 2 (ไทย)', title_en: 'General News 2 (Eng)',
detail_th: 'รายละเอียดข่าวทั่วไป 2 ภาษาไทย...', detail_en: 'Detail of general news 2 in English...',
image: { url: '/uploads/news_4.jpg' }, release_date: '04/01/2025', active: true, active_en: true, feature: false, type: 'OrgNews'
},
{
id: 5, title_th: 'ข่าวประชาสัมพันธ์หน่วยงาน', title_en: 'RTAF News Sample',
detail_th: 'รายละเอียดข่าวประชาสัมพันธ์ ภาษาไทยที่มีความยาวมากเกินไปเพื่อทดสอบการตัดคำ: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
detail_en: 'English detail of RTAF News Sample with excessive length to test truncation: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
image: { url: '/uploads/news_5.jpg' }, release_date: '05/01/2025', active: true, active_en: true, feature: false, type: 'HumanTechNews'
},
{
id: 6, title_th: 'ข่าวประชาสัมพันธ์หน่วยงานภายใน', title_en: 'RTAF Organization News Sample',
detail_th: 'รายละเอียดข่าวประชาสัมพันธ์หน่วยงานภายใน ภาษาไทย...', detail_en: 'Detail of RTAF Organization News Sample...',
image: { url: '/uploads/news_6.jpg' }, release_date: '06/01/2025', active: true, active_en: true, feature: false, type: 'OrgNews'
},
{
id: 7, title_th: 'ข่าวบริการกำลังพล ทอ.', title_en: 'RTAF Service News Sample',
detail_th: 'รายละเอียดข่าวบริการกำลังพล ทอ. ภาษาไทย...', detail_en: 'Detail of RTAF Service News Sample...',
image: { url: '/uploads/news_7.jpg' }, release_date: '07/01/2025', active: true, active_en: true, feature: false, type: 'InnovationNews'
},
{
id: 8, title_th: 'Press Release/โฆษก ทอ.', title_en: 'RTAF Press Release Sample',
detail_th: 'รายละเอียด Press Release/โฆษก ทอ. ภาษาไทย...', detail_en: 'Detail of RTAF Press Release Sample...',
image: { url: '/uploads/news_8.jpg' }, release_date: '08/01/2025', active: true, active_en: true, feature: false, type: 'GeneralPublic'
},
{
id: 9, title_th: 'ข่าวบริการประชาชน', title_en: 'Public Service News Sample',
detail_th: 'รายละเอียดข่าวบริการประชาชน ภาษาไทย...', detail_en: 'Detail of Public Service News Sample...',
image: { url: '/uploads/news_9.jpg' }, release_date: '09/01/2025', active: true, active_en: true, feature: false, type: 'EventActivities'
},
{
id: 10, title_th: 'ข่าวทั่วไป 3 (ไทย)', title_en: 'General News 3 (Eng)',
detail_th: 'รายละเอียดข่าวทั่วไป 3 ภาษาไทย...', detail_en: 'Detail of general news 3 in English...',
image: { url: '/uploads/news_10.jpg' }, release_date: '10/01/2025', active: true, active_en: true, feature: false, type: 'HumanTechNews'
},
],
// Mock Data TabNews
mockNewsData : [
{
id: 1,
title_th: "ข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 1", // RTAF News
title_en: "RTAF News Item 1",
detail_th: "รายละเอียดข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 1 เพื่อทดสอบการแสดงผลข้อมูล. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
detail_en: "Details for RTAF News Item 1 for testing purposes. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
image: { url: "/uploads/news_1.jpg" },
release_date: "07/01/2025",
active: true,
active_en: true,
type: "HumanTechNews", // <-- ให้ตรงกับ category ใน TabNews.vue
feature: false,
},
{
id: 2,
title_th: "ข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 2",
title_en: "RTAF News Item 2",
detail_th: "ข่าวนี้มีรายละเอียดที่ยาวมากจริงๆ เพื่อทดสอบการ truncate detail. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
detail_en: "This news has very long details to test detail truncation. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
image: { url: "/uploads/news_2.jpg" },
release_date: "07/03/2025",
active: true,
active_en: true,
type: "HumanTechNews", // <-- ให้ตรงกับ category ใน TabNews.vue
feature: false,
},
{
id: 3,
title_th: "ข่าวประชาสัมพันธ์ภายใน ชิ้นที่ 1", // RTAF Organization News
title_en: "RTAF Org News Item 1",
detail_th: "รายละเอียดข่าว นขต.ทอ. ชิ้นที่ 1 เพื่อทดสอบการแสดงผลข้อมูล. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
detail_en: "Details for RTAF Org News Item 1 for testing purposes. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
image: { url: "/uploads/news_3.jpg" },
release_date: "06/15/2025",
active: true,
active_en: true,
type: "OrgNews", // <-- ให้ตรงกับ category ใน TabNews.vue
feature: false,
},
{
id: 4,
title_th: "ข่าวประชาสัมพันธ์ภายใน ชิ้นที่ 2",
title_en: "RTAF Org News Item 2",
detail_th: "รายละเอียดข่าว นขต.ทอ. ชิ้นที่ 2 เพื่อทดสอบการแสดงผลข้อมูล. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
detail_en: "Details for RTAF Org News Item 2 for testing purposes. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
image: { url: "/uploads/news_4.jpg" },
release_date: "07/02/2025",
active: true,
active_en: true,
type: "OrgNews", // <-- ให้ตรงกับ category ใน TabNews.vue
feature: false,
},
{
id: 5,
title_th: "ข่าวนวัตกรรม ชิ้นที่ 1", // RTAF Service News (หรือชื่อที่เหมาะสมกับ InnovationNews)
title_en: "RTAF Service News Item 1",
detail_th: "รายละเอียดข่าวนวัตกรรม ชิ้นที่ 1 เพื่อทดสอบการแสดงผลข้อมูล. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
detail_en: "Details for RTAF Service News Item 1 for testing purposes. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
image: { url: "/uploads/news_5.jpg" },
release_date: "07/04/2025",
active: true,
active_en: true,
type: "InnovationNews", // <-- ให้ตรงกับ category ใน TabNews.vue
feature: false,
},
{
id: 6,
title_th: "ข่าวนวัตกรรม ชิ้นที่ 2",
title_en: "RTAF Service News Item 2",
detail_th: "รายละเอียดข่าวนวัตกรรม ชิ้นที่ 2 เพื่อทดสอบการแสดงผลข้อมูล. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
detail_en: "Details for RTAF Service News Item 2 for testing purposes. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
image: { url: "/uploads/news_5.jpg" },
release_date: "07/05/2025",
active: true,
active_en: true,
type: "InnovationNews", // <-- ให้ตรงกับ category ใน TabNews.vue
feature: false,
},
{
id: 7,
title_th: "Press Release ชิ้นที่ 1", // Press Release
title_en: "RTAF Press Release Item 1",
detail_th: "รายละเอียด Press Release ชิ้นที่ 1 เพื่อทดสอบการแสดงผลข้อมูล. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
detail_en: "Details for RTAF Press Release Item 1 for testing purposes. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
image: { url: "/uploads/news_4.jpg" },
release_date: "06/20/2025",
active: true,
active_en: true,
type: "GeneralPublic", // <-- ให้ตรงกับ category ใน TabNews.vue
feature: false,
},
{
id: 8,
title_th: "ข่าวบริการประชาชน ชิ้นที่ 1", // Public Service News
title_en: "Public Service News Item 1",
detail_th: "รายละเอียดข่าวบริการประชาชน ชิ้นที่ 1 เพื่อทดสอบการแสดงผลข้อมูล. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
detail_en: "Details for Public Service News Item 1 for testing purposes. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
image: { url: "/uploads/news_3.jpg" },
release_date: "07/06/2025",
active: true,
active_en: true,
type: "EventActivities", // <-- ให้ตรงกับ category ใน TabNews.vue
feature: false,
},
// เพิ่มเติมเพื่อให้มีข้อมูลเพียงพอสำหรับแต่ละแท็บ (อย่างน้อย 6 ชิ้นในแต่ละประเภท เพื่อให้ปุ่ม Read More แสดง)
{
id: 9,
title_th: "ข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 3",
title_en: "RTAF News Item 3",
detail_th: "รายละเอียดข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 3. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
detail_en: "Details for RTAF News Item 3. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
image: { url: "/uploads/news_2.jpg" },
release_date: "07/07/2025",
active: true,
active_en: true,
type: "HumanTechNews",
feature: false,
},
{
id: 10,
title_th: "ข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 4",
title_en: "RTAF News Item 4",
detail_th: "รายละเอียดข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 4. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
detail_en: "Details for RTAF News Item 4. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
image: { url: "/uploads/news_1.jpg" },
release_date: "07/07/2025",
active: true,
active_en: true,
type: "HumanTechNews",
feature: false,
},
{
id: 11,
title_th: "ข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 5",
title_en: "RTAF News Item 5",
detail_th: "รายละเอียดข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 5. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
detail_en: "Details for RTAF News Item 5. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
image: { url: "/uploads/news_1.jpg" },
release_date: "07/07/2025",
active: true,
active_en: true,
type: "HumanTechNews",
feature: false,
},
{
id: 12,
title_th: "ข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 6",
title_en: "RTAF News Item 6",
detail_th: "รายละเอียดข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 6. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
detail_en: "Details for RTAF News Item 6. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
image: { url: "/uploads/news_2.jpg" },
release_date: "07/07/2025",
active: true,
active_en: true,
type: "HumanTechNews",
feature: false,
},
{
id: 13,
title_th: "ข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 7 (สำหรับปุ่ม More)",
title_en: "RTAF News Item 7 (for More button)",
detail_th: "รายละเอียดข่าวประชาสัมพันธ์หน่วยงาน ชิ้นที่ 7. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
detail_en: "Details for RTAF News Item 7. Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
image: { url: "/uploads/news_3.jpg" },
release_date: "07/07/2025",
active: true,
active_en: true,
type: "HumanTechNews",
feature: false,
},
// เพิ่มข้อมูลสำหรับแต่ละ category ใน tabs ของคุณให้มีจำนวนเพียงพอที่จะแสดงผล
// ตัวอย่างเช่น สำหรับ OrgNews
{
id: 14,
title_th: "ข่าวประชาสัมพันธ์ภายใน ชิ้นที่ 3",
title_en: "RTAF Org News Item 3",
detail_th: "รายละเอียดข่าวประชาสัมพันธ์ภายใน ชิ้นที่ 3.",
detail_en: "Details for RTAF Org News Item 3.",
image: { url: "/uploads/news_4.jpg" },
release_date: "07/07/2025",
active: true,
active_en: true,
type: "OrgNews",
feature: false,
},
{
id: 15,
title_th: "ข่าวประชาสัมพันธ์ภายใน ชิ้นที่ 4",
title_en: "RTAF Org News Item 4",
detail_th: "รายละเอียดข่าวประชาสัมพันธ์ภายใน ชิ้นที่ 4.",
detail_en: "Details for RTAF Org News Item 4.",
image: { url: "/uploads/news_5.jpg" },
release_date: "07/07/2025",
active: true,
active_en: true,
type: "OrgNews",
feature: false,
},
{
id: 16,
title_th: "ข่าวประชาสัมพันธ์ภายใน ชิ้นที่ 5",
title_en: "RTAF Org News Item 5",
detail_th: "รายละเอียดข่าวประชาสัมพันธ์ภายใน ชิ้นที่ 5.",
detail_en: "Details for RTAF Org News Item 5.",
image: { url: "/uploads/news_6.jpg" },
release_date: "07/07/2025",
active: true,
active_en: true,
type: "OrgNews",
feature: false,
},
{
id: 17,
title_th: "ข่าวประชาสัมพันธ์ภายใน ชิ้นที่ 6",
title_en: "RTAF Org News Item 6",
detail_th: "รายละเอียดข่าวประชาสัมพันธ์ภายใน ชิ้นที่ 6.",
detail_en: "Details for RTAF Org News Item 6.",
image: { url: "/uploads/news_1.jpg" },
release_date: "07/07/2025",
active: true,
active_en: true,
type: "OrgNews",
feature: false,
},
{
id: 18,
title_th: "ข่าวประชาสัมพันธ์ภายใน ชิ้นที่ 7 (สำหรับปุ่ม More)",
title_en: "RTAF Org News Item 7 (for More button)",
detail_th: "รายละเอียดข่าวประชาสัมพันธ์ภายใน ชิ้นที่ 7.",
detail_en: "Details for RTAF Org News Item 7.",
image: { url: "/uploads/news_2.jpg" },
release_date: "07/07/2025",
active: true,
active_en: true,
type: "OrgNews",
feature: false,
},
// เพิ่มให้ครบทุก category เพื่อทดสอบ
],
mockBottomBanners: [
{ id: 1, banner_type: 'calousel', active: true, active_en: true, image: { url: '/uploads/slider_banner_1.jpg' }, link: 'www.google.co.th' },
],
mockFixedBanners: [
{ id: 1, banner_type: 'fixed', active: true, active_en: true, image: { url: '/uploads/news_1.jpg' }, link: '/some-internal-page' },
{ id: 2, banner_type: 'fixed', active: true, active_en: true, image: { url: '/uploads/news_2.jpg' }, link: '/weblink-4' },
{ id: 3, banner_type: 'fixed', active: true, active_en: true, image: { url: '/uploads/news_3.jpg' }, link: '/weblink-4' },
{ id: 4, banner_type: 'fixed', active: true, active_en: true, image: { url: '/uploads/news_4.jpg' }, link: '/weblink-4' },
{ id: 5, banner_type: 'fixed', active: true, active_en: true, image: { url: '/uploads/news_5.jpg' }, link: '/weblink-4' },
{ id: 6, banner_type: 'fixed', active: true, active_en: true, image: { url: '/uploads/news_6.jpg' }, link: '/weblink-4' },
{ id: 7, banner_type: 'fixed', active: true, active_en: true, image: { url: '/uploads/news_1.jpg' }, link: '/weblink-4' },
],
mockSliderBanners: [
{ id: 1, active: true, active_en: true, image: { url: '/uploads/slider_banner_1.jpg' }, link: '/weblink-4' },
{ id: 2, active: true, active_en: true, image: { url: '/uploads/slider_banner_2.jpg' }, link: '/weblink-4' },
{ id: 3, active: true, active_en: true, image: { url: '/uploads/slider_banner_3.jpg' }, link: '/weblink-4' },
{ id: 4, active: true, active_en: true, image: { url: '/uploads/slider_banner_4.jpg' }, link: '/weblink-4' },
{ id: 5, active: true, active_en: true, image: { url: '/uploads/slider_banner_5.jpg' }, link: '/weblink-4' },
],
mockBottomLinks: [
{ id: 1, active: true, active_en: true, image: [{ url: '/uploads/weblink-1.png' }], link: '/weblink-4', title: 'Print Magazine', title_en: 'Link 1' },
{ id: 2, active: true, active_en: true, image: [{ url: '/uploads/weblink-2.png' }], link: '/weblink-4', title: 'Rimberio', title_en: 'Link 2' },
{ id: 3, active: true, active_en: true, image: [{ url: '/uploads/weblink-3.png' }], link: '/weblink-4', title: 'Art Academy', title_en: 'Link 3' },
{ id: 4, active: true, active_en: true, image: [{ url: '/uploads/weblink-4.png' }], link: '/weblink-4', title: 'THE CREATIVE AGENCY', title_en: 'Link 4' },
{ id: 5, active: true, active_en: true, image: [{ url: '/uploads/weblink-5.png' }], link: '/weblink-5', title: 'Day Tech', title_en: 'Link 5' },
{ id: 6, active: true, active_en: true, image: [{ url: '/uploads/weblink-6.png' }], link: '/weblink-6', title: 'Artificial Intelligence', title_en: 'Link 6' },
],
}),
getters: {
// Getter สำหรับดึง Image Base URL
// หาก IMAGE_BASE_URL เป็น '', การรวมกับ /uploads/... จะได้ /uploads/... ซึ่ง Vite จะ serve จาก public folder
imageBaseUrl: (state) => IMAGE_BASE_URL,
// Getter สำหรับสถานะภาษา
checkLang: (state) => ({
isTh: state.isTh,
isEn: !state.isTh,
}),
// Getter สำหรับ Main Banner โดยเฉพาะ
mainBannerData: (state) => {
// ค้นหา Main Banner จาก calousels โดย id
return state.calousels.find(item => item.id === 1);
},
headerData: (state) => state.headers,
footerData: (state) => state.footer_menus,
allMainMenus: (state) => state.headers.main_menus,
allFooterMenus: (state) => state.footer_menus.menu,
},
actions: {
// Action สำหรับเปลี่ยนภาษา
toggleLanguage() {
this.isTh = !this.isTh;
},
// *** Action ใหม่: สำหรับ TabNews โดยเฉพาะ (return { data, total }) ***
async fetchTabNews(category, limit = 6) {
await new Promise(resolve => setTimeout(resolve, 100)); // Simulate API delay
const isTh = this.isTh;
// const currentDate = new Date(); // ไม่ต้องใช้แล้ว
// currentDate.setHours(0, 0, 0, 0); // ไม่ต้องใช้แล้ว
// ขั้นตอนที่ 1: กรองข้อมูลทั้งหมดที่เข้าเงื่อนไข (active, type) ก่อนที่จะ apply limit
const allMatchingNews = this.mockNewsData.filter(item => {
const isActive = isTh ? item.active : item.active_en;
const isCorrectType = item.type === category;
// ลบส่วนการตรวจสอบวันที่ออกไป
// const releaseDateParts = item.release_date ? item.release_date.split('/') : null;
// const itemReleaseDate = releaseDateParts ? new Date(parseInt(releaseDateParts[2]), parseInt(releaseDateParts[0]) - 1, parseInt(releaseDateParts[1])) : new Date(0);
// itemReleaseDate.setHours(0, 0, 0, 0);
// return isActive && isCorrectType && (itemReleaseDate <= currentDate); // เงื่อนไขเก่า
return isActive && isCorrectType; // เงื่อนไขใหม่: ไม่สนใจวันที่
});
// เก็บจำนวนรวมทั้งหมดก่อนการจำกัด (total count)
const totalCount = allMatchingNews.length;
// ขั้นตอนที่ 2: เรียงลำดับข้อมูลที่กรองแล้ว (ยังคงเรียงตามวันที่จากใหม่ไปเก่า)
allMatchingNews.sort((a, b) => {
const dateA = a.release_date ? new Date(parseInt(a.release_date.split('/')[2]), parseInt(a.release_date.split('/')[0]) - 1, parseInt(a.release_date.split('/')[1])) : new Date(0);
dateA.setHours(0, 0, 0, 0);
const dateB = b.release_date ? new Date(parseInt(b.release_date.split('/')[2]), parseInt(b.release_date.split('/')[0]) - 1, parseInt(b.release_date.split('/')[1])) : new Date(0);
dateB.setHours(0, 0, 0, 0);
return dateB.getTime() - dateA.getTime();
});
// ขั้นตอนที่ 3: จำกัดจำนวนตาม _limit สำหรับข้อมูลที่จะแสดงผล
const data = allMatchingNews.slice(0, limit);
return { data: data, total: totalCount }; // ส่ง Object กลับไปสำหรับ TabNews
},
// *** Action เดิม: find (ยังคง return เป็น Array เหมือนเดิมสำหรับ endpoint อื่นๆ) ***
async find(endpoint, queryParams = '') {
await new Promise(resolve => setTimeout(resolve, 100));
let data = [];
const isTh = this.isTh;
switch (endpoint) {
case 'calousels':
data = this.calousels.filter(item => {
const isActive = isTh ? item.active : item.active_en;
// --- Logic การกรองตาม startDate และ endDate ---
let isWithinDateRange = true;
if (item.startDate && item.endDate) {
try {
const today = new Date();
// แปลงวันที่จาก MM/DD/YYYY เป็น Date Object
// ต้องแน่ใจว่า item.startDate/endDate เป็น MM/DD/YYYY
const [startMonth, startDay, startYear] = item.startDate.split('/');
const [endMonth, endDay, endYear] = item.endDate.split('/');
const startDate = new Date(parseInt(startYear), parseInt(startMonth) - 1, parseInt(startDay));
const endDate = new Date(parseInt(endYear), parseInt(endMonth) - 1, parseInt(endDay));
// เซ็ตเวลาของวันนี้ให้เป็น 00:00:00 เพื่อเปรียบเทียบกับ startDate/endDate ที่มักจะไม่มีเวลา
today.setHours(0, 0, 0, 0);
isWithinDateRange = today >= startDate && today <= endDate;
} catch (e) {
console.error("Error parsing date for calousel item:", item, e);
isWithinDateRange = false; // ถ้า parse ไม่ได้ ถือว่าไม่อยู่ในช่วง
}
}
// --- จบ Logic การกรองตาม startDate และ endDate ---
return isActive && isWithinDateRange && item.order !== undefined; // ตรวจสอบ item.order เพื่อความปลอดภัย
}).sort((a, b) => a.order - b.order);
break;
case 'bottom-banners':
data = isTh ? this.mockBottomBanners.filter(b => b.active) : this.mockBottomBanners.filter(b => b.active_en);
break;
case 'slider-banners':
data = isTh ? this.mockSliderBanners.filter(b => b.active) : this.mockSliderBanners.filter(b => b.active_en);
break;
case 'bottom-links':
data = isTh ? this.mockBottomLinks.filter(l => l.active) : this.mockBottomLinks.filter(l => l.active_en);
break;
case 'contents':
case 'news':
data = this.mockNews.filter(item => {
const isActive = isTh ? item.active : item.active_en;
let match = isActive;
if (queryParams.includes('feature=true')) {
match = match && item.feature;
} else if (queryParams.includes('feature=false')) {
match = match && !item.feature;
}
return match;
});
data.sort((a, b) => {
const dateA = new Date(a.release_date.split('/').reverse().join('-'));
const dateB = new Date(b.release_date.split('/').reverse().join('-'));
return dateA - dateB;
});
const limitMatch = queryParams.match(/_limit=(\d+)/);
if (limitMatch) {
data = data.slice(0, parseInt(limitMatch[1]));
}
break;
case 'tabNews':
data = this.mockNewsData.filter(item => {
const isActive = isTh ? item.active : item.active_en;
let match = isActive;
if (queryParams.includes('feature=true')) {
match = match && item.feature;
} else if (queryParams.includes('feature=false')) {
match = match && !item.feature;
}
return match;
});
data.sort((a, b) => {
const dateA = new Date(a.release_date.split('/').reverse().join('-'));
const dateB = new Date(b.release_date.split('/').reverse().join('-'));
return dateA - dateB;
});
const limit = queryParams.match(/_limit=(\d+)/);
if (limit) {
data = data.slice(0, parseInt(limit[1]));
}
break;
default:
console.warn(`Endpoint ${endpoint} not mocked or handled by specific action.`);
data = [];
}
return data; // สำหรับ find action ทั่วไป จะยังคง return เป็น Array
},
// ในอนาคตถ้าต้องการดึงข้อมูล Header/Footer จาก API
async fetchHeaderFooterData() {
// ตัวอย่าง: const response = await axios.get('/api/header-footer');
// this.headers = response.data.headers;
// this.footer_menus = response.data.footer;
},
},
});

View File

@ -0,0 +1,38 @@
// src/stores/landingPageView.js
import { defineStore } from 'pinia';
const IMAGE_BASE_URL = ''; // ให้เป็น string ว่างเปล่าเพื่อใช้ Path สัมพัทธ์กับ Public folder
export const useLandingPageViewStore = defineStore('landingPageView', {
state: () => ({
// ข้อมูล Mock สำหรับ Main Banner (จาก app.js เดิม)
mainBanner: {
url: '/images/MainBanner.jpg', // Path รูปภาพ Main Banner
link: 'www.google.com', // ลิงก์สำหรับรูป
startDate: '01/01/2025', // ต้องเป็น MM/DD/YYYY
endDate: '12/31/2025', // ต้องเป็น MM/DD/YYYY (กำหนดให้แสดงตลอดปี)
},
// ถ้ามีข้อมูลอื่นๆ ที่เกี่ยวข้องกับ Landing Page โดยเฉพาะ ก็สามารถเพิ่มที่นี่ได้
// เช่น backgroundImage, logoImage, text
backgroundImage: { url: '/images/moroccan-flower.png' }, // ตัวอย่าง
}),
getters: {
imageBaseUrl: (state) => IMAGE_BASE_URL,
// Getter สำหรับดึงข้อมูล Main Banner
mainBannerData: (state) => state.mainBanner,
// getters อื่นๆ สำหรับข้อมูลเฉพาะ Landing Page
getBackgroundImage: (state) => `${IMAGE_BASE_URL}${state.backgroundImage?.url || ''}`,
},
actions: {
// ถ้าในอนาคต Landing Page มีการดึงข้อมูลจาก API จริงๆ ค่อยมาใส่ใน Action นี้
async fetchLandingPageData() {
// จำลองการโหลดข้อมูล หรือดึงข้อมูลจาก API จริงๆ
await new Promise(resolve => setTimeout(resolve, 100));
console.log("Landing Page Data Loaded from dedicated store.");
// ในกรณีที่มี API จริง อาจจะมีการอัปเดต state ตรงนี้
},
},
});

2
src/style.css Normal file
View File

@ -0,0 +1,2 @@
@import "tailwindcss";
@plugin "daisyui";

114
src/views/ContentView.vue Normal file
View File

@ -0,0 +1,114 @@
// src/views/ContentView.vue
<template>
<div class="container mx-auto p-4 md:p-8">
<div v-if="newsItem" class="bg-white shadow-lg rounded-lg overflow-hidden">
<div class="relative h-64 md:h-96">
<img
v-if="newsItem.image && newsItem.image.url"
:src="`${appStore.imageBaseUrl}${newsItem.image.url}`"
:alt="appStore.checkLang.isTh ? newsItem.title_th : newsItem.title_en"
class="w-full h-full object-cover"
/>
<div v-else class="w-full h-full bg-gray-200 flex items-center justify-center text-gray-500">
No Image Available
</div>
</div>
<div class="p-6 md:p-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4 leading-tight">
{{ appStore.checkLang.isTh ? newsItem.title_th : newsItem.title_en }}
</h1>
<p class="text-sm text-gray-500 mb-6">
<span v-if="newsItem.release_date">
{{ appStore.checkLang.isTh ? 'เผยแพร่เมื่อ' : 'Published on' }}:
{{ formatDate(newsItem.release_date) }}
</span>
</p>
<div class="prose max-w-none text-gray-800 leading-relaxed break-words whitespace-pre-line">
{{ appStore.checkLang.isTh ? newsItem.detail_th : newsItem.detail_en }}
</div>
</div>
</div>
<div v-else class="text-center text-gray-600 py-16">
<p class="text-2xl font-semibold mb-4">
{{ appStore.checkLang.isTh ? 'ไม่พบข่าวสารนี้ หรือข่าวสารนี้ไม่พร้อมใช้งานในภาษาปัจจุบัน' : 'News not found or not available in the current language.' }}
</p>
<router-link to="/home" class="btn btn-primary">
{{ appStore.checkLang.isTh ? 'กลับหน้าหลัก' : 'Back to Home' }}
</router-link>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router'; // route parameters
import { useAppStore } from '@/stores/app.js';
import { RouterLink } from 'vue-router'; //
const appStore = useAppStore();
const route = useRoute(); // Instance useRoute
const newsItem = ref(null); //
// Function to fetch news detail by ID
const fetchNewsDetail = async (id) => {
// API await axios.get(`/api/news/${id}`);
// mock data store
const foundNews = appStore.mockNews.find(item => item.id == id); // == string/number
if (foundNews) {
// active active_en
if (appStore.isTh && foundNews.active) {
newsItem.value = foundNews;
} else if (!appStore.isTh && foundNews.active_en) { // !appStore.isTh
newsItem.value = foundNews;
} else {
newsItem.value = null; // active
}
} else {
newsItem.value = null;
}
};
const formatDate = (dateString) => {
if (!dateString) return '';
const parts = dateString.split('/'); // Assumes MM/DD/YYYY
if (parts.length === 3) {
const [month, day, year] = parts;
const dateObj = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
const formattedDay = String(dateObj.getDate()).padStart(2, '0');
const formattedMonth = String(dateObj.getMonth() + 1).padStart(2, '0');
const formattedYear = dateObj.getFullYear();
return `${formattedDay}/${formattedMonth}/${formattedYear}`;
}
return dateString;
};
// Component mount
onMounted(() => {
fetchNewsDetail(route.params.id);
});
// Watch for changes in route.params.id ( Link )
watch(() => route.params.id, (newId) => {
fetchNewsDetail(newId);
});
// Watch for language changes and re-fetch the news detail
watch(() => appStore.isTh, () => {
fetchNewsDetail(route.params.id);
});
</script>
<style scoped>
/* หากใช้ DaisyUI/TailwindCSS ก็ไม่จำเป็นต้องมี style scoped มากนัก */
.prose {
/* Tailwind CSS's @tailwindcss/typography plugin provides 'prose' class for nicely formatted text.
If you haven't installed it, these styles can be adjusted manually or by adding the plugin. */
max-width: 80ch; /* Limit line length for readability */
margin-left: auto;
margin-right: auto;
}
.whitespace-pre-line {
white-space: pre-line; /* Keeps line breaks from your data */
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<div class="p-4 md:p-8">
<h1 class="text-4xl font-bold text-center mb-8">Dashboard Overview</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div class="col-span-1">
<h2 class="text-2xl font-semibold mb-4">World News</h2>
<div class="card bg-base-100 shadow-xl p-4">
<p>This is where your World News panel would go, converted from NewsPanel.jsx.</p>
<img src="/images/Welcome_Page_680728.jpg" alt="News Video" class="w-full h-auto mt-4 rounded-lg">
<p class="text-sm text-gray-500 mt-2">Example video thumbnail from World News section.</p>
</div>
</div>
<div class="col-span-1">
<h2 class="text-2xl font-semibold mb-4">World Statistics</h2>
<div class="card bg-base-100 shadow-xl p-4">
<p>This is where your Statistics panel would go, converted from StatsPanel.jsx.</p>
<ul class="list-disc list-inside mt-4">
<li>Market Review</li>
<li>Currencies</li>
<li>Population</li>
<li>Energy</li>
<li>Russian Economy</li>
</ul>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="col-span-1">
<h2 class="text-2xl font-semibold mb-4">Active Satellites</h2>
<div class="card bg-base-300 shadow-xl p-4 min-h-[400px] flex items-center justify-center">
<p class="text-lg text-gray-500">
[3D Globe Simulation Placeholder - Convert EarthBody.jsx, GlobeCanvas.jsx, etc. here]
</p>
<img src="/images/Welcome_Page_680728.jpg" alt="3D Globe" class="absolute w-full h-full object-cover opacity-70">
</div>
</div>
<div class="col-span-1">
<h2 class="text-2xl font-semibold mb-4">Satellite Details</h2>
<div class="card bg-base-100 shadow-xl p-4">
<ul class="list-disc list-inside">
<li>ISS (1.05R, 51°)</li>
<li>Hubble Space Telescope (1.07R, 28.5°)</li>
<li>Starlink-123 (1.10R, 53.0°)</li>
</ul>
<p class="text-sm text-gray-600 mt-4">
Details for active satellites would be displayed here, possibly from PopUpDetail.jsx.
</p>
<div class="mt-4 p-2 bg-base-200 rounded">
<p><strong>Hubble Space Telescope:</strong> </p>
<p class="text-sm">The Hubble Space Telescope is a space telescope that was launched into low Earth orbit in 1990 and remains in operation.</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
// import NewsPanel from '@/components/NewsPanel.vue';
// import StatsPanel from '@/components/StatsPanel.vue';
// import GlobeCanvas from '@/components/GlobeCanvas.vue'; // This and related components need careful conversion
// No specific data or logic needed for this mock view,
// as it primarily contains placeholders for other components.
</script>
<style scoped>
/* Specific styles for DashboardView if needed */
</style>

28
src/views/HomeView.vue Normal file
View File

@ -0,0 +1,28 @@
// src/views/HomeView.vue ( DefaultLayout)
<template>
<div class="home-page-container">
<Calousels />
<News />
<TabNews />
<BannerCalousels />
<WebLinks />
</div>
</template>
<script setup>
// Import Component HomeView
import Calousels from '@/components/Calousels.vue';
import News from '@/components/News.vue';
import TabNews from '@/components/TabNews.vue';
import BannerCalousels from '@/components/BannerCalousels.vue';
import WebLinks from '@/components/WebLinks.vue';
// Vue.js Navigation Guards router/index.js
</script>
<style scoped>
.home-page-container {
/* เพิ่มสไตล์สำหรับ container ของหน้า Home (เช่น padding) */
padding: 1rem;
}
</style>

View File

@ -0,0 +1,100 @@
// src/views/LandingPageView.vue
<template>
<div class="container mx-auto p-4 md:p-8">
<div class="flex flex-wrap -mx-4">
<div class="w-full px-4">
<div class="flex flex-col items-center justify-center">
<template v-if="check === true">
<a
v-if="landingPageViewStore.mainBannerData && landingPageViewStore.mainBannerData.link"
:href="`//${landingPageViewStore.mainBannerData.link}`"
target="_blank"
rel="noopener noreferrer"
class="inline-block"
>
<img
:src="mainBannerSrc"
alt="Main Banner (Clickable)"
class="max-w-[980px] max-h-[600px] w-full h-auto transition-transform duration-500 transform ease-in-out hover:scale-105"
/>
</a>
<img
v-else-if="mainBannerSrc"
:src="mainBannerSrc"
alt="Main Banner"
class="max-w-[980px] max-h-[600px] w-full h-auto"
/>
</template>
<router-link to="/home" class="btn btn-lg mt-4 normal-case
bg-yellow-500 text-white /* สีพื้นหลังเหลือง */
hover:bg-yellow-600 /* สีพื้นหลังเมื่อโฮเวอร์ */
active:bg-yellow-700 /* สีพื้นหลังเมื่อคลิก */
focus:ring-2 focus:ring-yellow-400 focus:ring-offset-2 /* Focus ring */
shadow-md /* เพิ่มเงาเล็กน้อย */
rounded-lg /* ทำให้โค้งมนขึ้นเล็กน้อย */
">
เขาสเวบไซต
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
// import useLandingPageViewStore
import { useLandingPageViewStore } from '@/stores/landingPageView';
import { RouterLink } from 'vue-router';
// useLandingPageViewStore
const landingPageViewStore = useLandingPageViewStore();
const check = ref(false);
const mainBannerSrc = computed(() => {
// landingPageViewStore
const data = landingPageViewStore.mainBannerData;
if (data && data.url) { // .url mainBannerData
return `${landingPageViewStore.imageBaseUrl}${data.url}`;
}
return '';
});
onMounted(async () => { // async fetchLandingPageData
// fetchLandingPageData Store
await landingPageViewStore.fetchLandingPageData();
const mainBannerConfig = landingPageViewStore.mainBannerData;
if (mainBannerConfig && mainBannerConfig.startDate && mainBannerConfig.endDate) {
const parseDateString = (dateString) => {
const parts = dateString.split('/');
return new Date(parseInt(parts[2]), parseInt(parts[0]) - 1, parseInt(parts[1]));
};
let from = parseDateString(mainBannerConfig.startDate);
let to = parseDateString(mainBannerConfig.endDate);
let today = new Date();
let dd = String(today.getDate()).padStart(2, '0');
let mm = String(today.getMonth() + 1).padStart(2, '0');
let yyyy = today.getFullYear();
let currentDate = `${mm}/${dd}/${yyyy}`;
let checkDate = parseDateString(currentDate);
check.value = checkDate >= from && checkDate <= to;
} else {
check.value = true;
}
// Debugging logs:
console.log('Main Banner Data from LandingPageViewStore:', landingPageViewStore.mainBannerData);
console.log('Main Banner Image Source:', mainBannerSrc.value);
console.log('Check date result (check.value):', check.value);
});
</script>
<style scoped>
/* สไตล์ Tailwind CSS และ DaisyUI จะจัดการเรื่องความสวยงามเป็นหลัก */
</style>

View File

@ -0,0 +1,133 @@
// src/views/NewsCategoryView.vue
<template>
<div class="container mx-auto p-4 md:p-8">
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 mb-6 border-b pb-4">
{{ appStore.checkLang.isTh ? `ข่าวสารหมวดหมู่: ${categoryTitleTh}` : `News Category: ${categoryTitleEn}` }}
</h2>
<div v-if="newsList.length > 0" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="item in newsList"
:key="item.id"
class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow duration-300"
>
<router-link :to="`/content/${item.id}`" class="block">
<div class="w-full h-48 overflow-hidden">
<img
v-if="item.image && item.image.url"
class="w-full h-full object-cover transition-transform duration-300 hover:scale-105"
:src="`${appStore.imageBaseUrl}${item.image.url}`"
:alt="appStore.checkLang.isTh ? item.title_th : item.title_en"
/>
<div v-else class="w-full h-full bg-gray-200 flex items-center justify-center text-gray-500">
No Image
</div>
</div>
<div class="p-4">
<h3 class="font-semibold text-lg text-gray-800 hover:text-primary-focus leading-tight mb-2">
{{ appStore.checkLang.isTh ? item.title_th : item.title_en }}
</h3>
<p class="text-sm text-gray-600 leading-snug mb-3">
{{ truncateDetail(appStore.checkLang.isTh ? item.detail_th : item.detail_en, 120) }}
</p>
<div class="text-xs text-gray-500">
<span v-if="item.release_date">{{ formatDate(item.release_date) }}</span>
</div>
</div>
</router-link>
</div>
</div>
<div v-else class="text-center text-gray-600 py-16">
<p class="text-2xl font-semibold mb-4">
{{ appStore.checkLang.isTh ? 'ไม่พบข่าวสารในหมวดหมู่นี้ หรือข่าวสารนี้ไม่พร้อมใช้งานในภาษาปัจจุบัน' : 'No news found in this category or not available in the current language.' }}
</p>
<router-link to="/home" class="btn bg-[#1b3872] text-white hover:bg-[#1b3872]/90 rounded-none border-none shadow-md">
{{ appStore.checkLang.isTh ? 'กลับหน้าหลัก' : 'Back to Home' }}
</router-link>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAppStore } from '@/stores/app.js';
import { RouterLink } from 'vue-router';
const appStore = useAppStore();
const route = useRoute();
const newsList = ref([]);
// Computed property
const categoryTitleTh = computed(() => {
// appStore.tabs Array
if (appStore.tabs && Array.isArray(appStore.tabs) && appStore.tabs.length > 0) {
const tab = appStore.tabs.find(t => t.category === route.params.category);
return tab ? tab.title : 'ไม่ระบุหมวดหมู่';
}
return 'กำลังโหลด...';
});
const categoryTitleEn = computed(() => {
if (appStore.tabs && Array.isArray(appStore.tabs) && appStore.tabs.length > 0) {
const tab = appStore.tabs.find(t => t.category === route.params.category);
return tab ? tab.title_en : 'Unspecified Category';
}
return 'Loading...';
});
const truncateDetail = (text, maxLength) => {
if (!text) return '';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
};
const formatDate = (dateString) => {
if (!dateString) return '';
const parts = dateString.split('/'); // Assumes MM/DD/YYYY
if (parts.length === 3) {
const [month, day, year] = parts;
const dateObj = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
const formattedDay = String(dateObj.getDate()).padStart(2, '0');
const formattedMonth = String(dateObj.getMonth() + 1).padStart(2, '0');
const formattedYear = dateObj.getFullYear();
return `${formattedDay}/${formattedMonth}/${formattedYear}`;
}
return dateString;
};
const fetchNewsForCategory = async (category) => {
try {
// appStore.find active, active_en, release_date feature
const allNews = await appStore.find('tabNews', ''); // filter
const filteredNews = allNews.filter(item => {
return item.type === category;
});
newsList.value = filteredNews;
} catch (error) {
console.error(`Error fetching news for category ${category}:`, error);
newsList.value = [];
}
};
onMounted(() => {
fetchNewsForCategory(route.params.category);
});
watch(() => route.params.category, (newCategory) => {
fetchNewsForCategory(newCategory);
});
// Watch for language changes and re-fetch the news list
watch(() => appStore.checkLang.isTh, () => { // ****
fetchNewsForCategory(route.params.category);
});
</script>
<style scoped>
</style>

View File

@ -0,0 +1,60 @@
// src/views/NotFoundView.vue
<template>
<div class="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 text-center bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl">
<div class="text-9xl font-extrabold text-blue-600 dark:text-blue-400 animate-bounce">404</div>
<h1 class="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
{{ appStore.checkLang.isTh ? 'ไม่พบหน้าดังกล่าว' : 'Page Not Found' }}
</h1>
<p class="mt-2 text-lg text-gray-600 dark:text-gray-300">
{{ appStore.checkLang.isTh ? 'ขออภัย, เราไม่พบหน้าที่คุณกำลังมองหา' : 'Sorry, we couldn\'t find the page you\'re looking for.' }}
</p>
<div class="mt-6">
<router-link
to="/"
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path>
</svg>
{{ appStore.checkLang.isTh ? 'กลับหน้าหลัก' : 'Go to Homepage' }}
</router-link>
</div>
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
{{ appStore.checkLang.isTh ? 'หากคุณคิดว่านี่เป็นข้อผิดพลาด โปรดติดต่อผู้ดูแลระบบ' : 'If you believe this is an error, please contact the administrator.' }}
</div>
</div>
</div>
</template>
<script setup>
import { useAppStore } from '@/stores/app'; // Assuming you have appStore for language
import { RouterLink } from 'vue-router'; // Required for <router-link>
const appStore = useAppStore(); // Initialize appStore
</script>
<style scoped>
/* Optional: Add custom keyframes for bounce animation if not using utility classes directly */
@keyframes bounce {
0%, 100% {
transform: translateY(-25%);
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
}
50% {
transform: translateY(0);
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
}
.animate-bounce {
animation: bounce 1s infinite;
}
/* Optional: Dark mode adjustments */
.dark\:bg-gray-900 { background-color: #1a202c; }
.dark\:bg-gray-800 { background-color: #2d3748; }
.dark\:text-blue-400 { color: #63b3ed; }
.dark\:text-white { color: #ffffff; }
.dark\:text-gray-300 { color: #cbd5e0; }
.dark\:text-gray-400 { color: #a0aec0; }
</style>

17
vite.config.js Normal file
View File

@ -0,0 +1,17 @@
// vite.config.js
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath, URL } from 'node:url'; // <<< ต้องเพิ่มบรรทัดนี้
export default defineConfig({
plugins: [
tailwindcss(), // ตรวจสอบว่า @tailwindcss/vite ถูกติดตั้งและใช้ถูกต้อง
vue()
],
resolve: { // จะได้ใช้ import ... from '@/...'; ได้
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
});