first commit
This commit is contained in:
5
.firebaserc
Normal file
5
.firebaserc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "normadbobu"
|
||||
}
|
||||
}
|
||||
69
.gitignore
vendored
Normal file
69
.gitignore
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
firebase-debug.log*
|
||||
firebase-debug.*.log*
|
||||
|
||||
# Firebase cache
|
||||
.firebase/
|
||||
|
||||
# Firebase config
|
||||
|
||||
# Uncomment this if you'd like others to create their own Firebase project.
|
||||
# For a team working on the same Firebase project(s), it is recommended to leave
|
||||
# it commented so all members can deploy to the same project(s) in .firebaserc.
|
||||
# .firebaserc
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# dataconnect generated files
|
||||
.dataconnect
|
||||
24
bobu/.gitignore
vendored
Normal file
24
bobu/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
75
bobu/README.md
Normal file
75
bobu/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
63
bobu/app/app.vue
Normal file
63
bobu/app/app.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="relative min-h-screen">
|
||||
<!-- 1) Fixed, full-viewport background -->
|
||||
<img
|
||||
:src="MAIN.background"
|
||||
alt="background"
|
||||
class="fixed inset-0 w-full h-full object-cover dark:hidden"
|
||||
/>
|
||||
|
||||
<!-- 2) Main content (scrolls) -->
|
||||
<div
|
||||
class="relative z-10 w-full max-w-screen-lg mx-auto dark:max-w-full dark:mx-0"
|
||||
>
|
||||
<NuxtLayout :name="headerStore.currentLayout">
|
||||
<NuxtPage :key="$route.fullPath" />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
|
||||
<!-- 3) Title image, fixed to the right -->
|
||||
<div class="pointer-events-none">
|
||||
<img
|
||||
:src="MAIN.title"
|
||||
alt="main title"
|
||||
class="fixed top-1/2 right-0 h-48 -translate-y-1/2 object-contain hidden 2xl:block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useHeaderStateStore } from '~/stores/headerStateStore';
|
||||
import { MAIN } from '~/data/assets';
|
||||
|
||||
const contentLoaded = ref(false);
|
||||
const headerStore = useHeaderStateStore(); // Initialize the header state store
|
||||
|
||||
//naver map api
|
||||
const clientId = 'u5ju5efq99';
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
// new personal-API syntax:
|
||||
src: `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${clientId}`,
|
||||
type: 'text/javascript',
|
||||
async: true,
|
||||
defer: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
filter: grayscale(1rem);
|
||||
}
|
||||
</style>
|
||||
74
bobu/app/components/FeaturesCarousel.vue
Normal file
74
bobu/app/components/FeaturesCarousel.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<section class="relative py-8 px-4 bg-transparent">
|
||||
<Carousel v-bind="config" class="mx-auto max-w-screen-xl">
|
||||
<Slide
|
||||
v-for="(imgSrc, idx) in images"
|
||||
:key="idx"
|
||||
class="relative h-64 sm:h-80 md:h-96 lg:h-[500px] overflow-hidden rounded-lg shadow-lg"
|
||||
>
|
||||
<img
|
||||
:src="imgSrc"
|
||||
alt="Slide image"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</Slide>
|
||||
|
||||
<!-- Add navigation arrows and pagination indicators -->
|
||||
<template #addons>
|
||||
<Navigation />
|
||||
<Pagination />
|
||||
</template>
|
||||
</Carousel>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Carousel, Slide, Navigation, Pagination } from 'vue3-carousel';
|
||||
import 'vue3-carousel/dist/carousel.css';
|
||||
import { MAIN_IMAGES } from '@/data/assets';
|
||||
|
||||
// Build a simple array of URLs from your MAIN_IMAGES object
|
||||
const images = computed<string[]>(() => Object.values(MAIN_IMAGES));
|
||||
|
||||
// Carousel configuration
|
||||
const config = {
|
||||
height: 500, // fixed height (px); adjust as needed (500px here)
|
||||
itemsToShow: 1, // always show one slide at a time
|
||||
gap: 0, // no horizontal gap between slides
|
||||
wrapAround: true, // loop infinitely
|
||||
mouseWheel: false, // disable mouse-wheel navigation
|
||||
autoplay: 4000, // change slide every 4 seconds
|
||||
pauseAutoplayOnHover: true,
|
||||
breakpoints: {
|
||||
1280: { itemsToShow: 1, height: 500 }, // ≥2xl
|
||||
1024: { itemsToShow: 1, height: 450 }, // ≥xl
|
||||
768: { itemsToShow: 1, height: 400 }, // ≥md
|
||||
0: { itemsToShow: 1, height: 300 }, // mobile
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Customize arrow & pagination colors via CSS variables */
|
||||
.carousel {
|
||||
--vc-nav-background: rgba(255, 255, 255, 0.8);
|
||||
--vc-nav-border-radius: 100%;
|
||||
--vc-nav-icon-size: 1.25rem;
|
||||
--vc-pgn-background-color: rgba(255, 255, 255, 0.5);
|
||||
--vc-pgn-active-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
/* Optional: subtle shadow behind navigation buttons */
|
||||
.carousel__navigation-button {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Ensure all images are rounded and cover the slide area */
|
||||
img {
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
30
bobu/app/components/LoadingOverlay.vue
Normal file
30
bobu/app/components/LoadingOverlay.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50"
|
||||
>
|
||||
<div class="animate-pulse">
|
||||
<div class="flex justify-center items-center py-10">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'spinner']"
|
||||
spin-pulse
|
||||
size="2xl"
|
||||
style="color: #ffffff"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-white text-2xl">{{ loadingMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
isLoading: Boolean,
|
||||
loadingMessage: {
|
||||
type: String,
|
||||
default: '잠시만 기다려주세요..',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
29
bobu/app/components/LoadingSection.vue
Normal file
29
bobu/app/components/LoadingSection.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="w-full flex flex-col items-center justify-center py-4 min-h-[18rem]"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'spinner']"
|
||||
spin-pulse
|
||||
size="2xl"
|
||||
class="text-gray-500 animate-spin"
|
||||
/>
|
||||
<p class="mt-2 text-gray-500 text-sm text-center">
|
||||
{{ loadingMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingMessage: {
|
||||
type: String,
|
||||
default: '잠시만 기다려주세요..',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
128
bobu/app/components/MainFooter.vue
Normal file
128
bobu/app/components/MainFooter.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<footer
|
||||
class="bg-gray-100 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl overflow-hidden px-6 py-12 sm:py-16 lg:px-8">
|
||||
<div class="flex justify-center mb-8">
|
||||
<NuxtLink to="/" aria-label="Bobu Home">
|
||||
<span class="sr-only">Bobu</span>
|
||||
<img
|
||||
class="h-10 w-auto block dark:hidden transition-all duration-300"
|
||||
:src="LOGOS.RedGaro"
|
||||
alt="Bobu Logo Light"
|
||||
/>
|
||||
<img
|
||||
class="h-10 w-auto hidden dark:block transition-all duration-300"
|
||||
:src="LOGOS.White"
|
||||
alt="Bobu Logo Dark"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
class="-mb-6 flex flex-wrap justify-center gap-x-6 sm:gap-x-12 gap-y-3 text-sm"
|
||||
aria-label="Footer Navigation"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="item in navigation.main"
|
||||
:key="item.name"
|
||||
:to="item.href"
|
||||
class="font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
|
||||
>
|
||||
{{ item.name }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<hr class="my-8 border-gray-300 dark:border-gray-700 w-1/2 mx-auto" />
|
||||
|
||||
<div
|
||||
class="mt-8 text-center text-sm text-gray-600 dark:text-gray-400 space-y-1"
|
||||
>
|
||||
<p>
|
||||
<span class="font-semibold">사업자등록번호 :</span>
|
||||
{{ companyInfo.registrationNumber }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-semibold">대표이사 :</span>
|
||||
{{ companyInfo.president }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-semibold">주소 :</span> {{ companyInfo.address }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-semibold">연락처 :</span> {{ companyInfo.phone }}
|
||||
<span class="text-gray-400 dark:text-gray-600 mx-1">|</span>
|
||||
<span class="font-semibold">팩스 :</span> {{ companyInfo.fax }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-semibold">이메일 : </span> {{ companyInfo.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- <div class="mt-10 flex justify-center gap-x-8 sm:gap-x-10">
|
||||
<a
|
||||
v-for="item in navigation.social"
|
||||
:key="item.name"
|
||||
:href="item.href"
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="sr-only">{{ item.name }}</span>
|
||||
<component :is="item.icon" class="size-6" aria-hidden="true" />
|
||||
</a>
|
||||
</div> -->
|
||||
|
||||
<p
|
||||
class="mt-10 text-center text-xs sm:text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
© {{ companyInfo.copyYear }} {{ companyInfo.name }}. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { LOGOS } from '@/data/assets'; // Make sure you have LOGOS defined/imported
|
||||
import { companyInfo } from '@/data/config';
|
||||
|
||||
// Define Navigation (Consider moving social icons to components)
|
||||
const navigation = {
|
||||
main: [
|
||||
// Use actual paths or hashes relevant to your site structure
|
||||
{ name: '회사소개', href: '/about' },
|
||||
{ name: '쇼핑하기', href: '/shop' },
|
||||
{ name: '공유 오피스 예약', href: '/office' },
|
||||
{ name: '문의하기', href: '/contact' },
|
||||
// { name: '개인정보처리방침', href: '/privacy' },
|
||||
// { name: '이용약관', href: '/terms' },
|
||||
],
|
||||
social: [
|
||||
{
|
||||
name: 'Instagram',
|
||||
href: '#', // Replace with your actual Instagram link
|
||||
// SUGGESTION: Replace this complex inline icon definition
|
||||
// with an imported component like <InstagramIcon /> or library icon
|
||||
icon: defineComponent({
|
||||
render: () =>
|
||||
h('svg', { fill: 'currentColor', viewBox: '0 0 24 24' }, [
|
||||
h('path', {
|
||||
'fill-rule': 'evenodd',
|
||||
// Example Instagram Path (replace with actual minimal path if possible)
|
||||
d: 'M12 2.163c3.204 0 3.584.012 4.85.07 1.272.058 2.163.248 2.948.568.859.333 1.564.824 2.27 1.531.707.707 1.198 1.411 1.531 2.27.32.785.51 1.676.568 2.948.058 1.265.07 1.645.07 4.85s-.012 3.584-.07 4.85c-.058 1.272-.248 2.163-.568 2.948-.333.859-.824 1.564-1.531 2.27-.707.707-1.411 1.198-2.27 1.531-.785.32-1.676.51-2.948.568-1.265.058-1.645.07-4.85.07s-3.584-.012-4.85-.07c-1.272-.058-2.163-.248-2.948-.568-.859-.333-1.564-.824-2.27-1.531-.707-.707-1.198-1.411-1.531-2.27-.32-.785-.51-1.676-.568-2.948-.058-1.265-.07-1.645-.07-4.85s.012-3.584.07-4.85c.058-1.272.248-2.163.568-2.948.333-.859.824-1.564 1.531 2.27.707-.707 1.411-1.198 2.27-1.531.785-.32 1.676-.51 2.948-.568C8.416 2.175 8.796 2.163 12 2.163m0-1.014c-3.247 0-3.65.013-4.924.072-1.356.06-2.328.25-3.17.58C2.97 2.21 2.24 2.713 1.53 3.423.82 4.133.317 4.862.09 5.787c-.33 1.04-.52 2.012-.58 3.368C-.07 10.32-.083 10.727-.083 12s.013 1.68.072 2.95c.06 1.356.25 2.328.58 3.17.227.925.73 1.655 1.44 2.365.71.71 1.44.213 2.365.44.842.33 1.814.52 3.17.58 1.273.06 1.676.072 4.924.072s3.65-.013 4.924-.072c1.356-.06 2.328-.25 3.17-.58.925-.227 1.655-.73 2.365-1.44.71-.71 1.213-1.44 1.44-2.365.33-.842.52-1.814.58-3.17.06-1.27.072-1.675.072-4.95s-.013-3.678-.072-4.948c-.06-1.356-.25-2.328-.58-3.17-.227-.925-.73-1.655-1.44-2.365C21.87 1.317 21.14.814 20.216.586c-.842-.33-1.814-.52-3.17-.58C15.65.013 15.247 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.88 1.44 1.44 0 000-2.88z',
|
||||
'clip-rule': 'evenodd',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
},
|
||||
// Add more social links here if needed
|
||||
// { name: 'Facebook', href: '#', icon: FacebookIconComponent },
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any component-specific styles here if needed */
|
||||
</style>
|
||||
189
bobu/app/components/MainHeader.vue
Normal file
189
bobu/app/components/MainHeader.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<header class="relative isolate z-10 bg-white dark:bg-gray-900 shadow-sm">
|
||||
<nav
|
||||
class="mx-auto flex max-w-7xl min-h-20 lg:min-h-32 items-end justify-between p-6 lg:px-8"
|
||||
aria-label="Global Navigation"
|
||||
>
|
||||
<!-- MobileSidebar Open Button -->
|
||||
<div class="flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
class="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-700 dark:text-white"
|
||||
@click="mobileMenuOpen = true"
|
||||
aria-label="Open main menu"
|
||||
>
|
||||
<Bars3Icon class="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<!-- Logo -->
|
||||
<div class="flex">
|
||||
<NuxtLink to="/" class="-m-1.5 p-1.5" aria-label="Bobu Home">
|
||||
<span class="sr-only">Bobu</span>
|
||||
<img
|
||||
class="h-14 w-auto block dark:hidden transition-all duration-300"
|
||||
:src="LOGOS.Red"
|
||||
alt="Bobu Logo Light"
|
||||
/>
|
||||
<img
|
||||
class="h-14 w-auto hidden dark:block transition-all duration-300"
|
||||
:src="LOGOS.White"
|
||||
alt="Bobu Logo Dark"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<!-- <PopoverGroup class="hidden lg:flex lg:gap-x-12">
|
||||
<NuxtLink
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
:to="item.href"
|
||||
class="text-sm font-semibold leading-6 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
{{ item.name }}
|
||||
</NuxtLink>
|
||||
</PopoverGroup> -->
|
||||
|
||||
<!-- Desktop HeaderActions -->
|
||||
<div class="flex justify-end">
|
||||
<HeaderActions />
|
||||
</div>
|
||||
</nav>
|
||||
<!-- MobileSidebar -->
|
||||
|
||||
<Dialog
|
||||
as="div"
|
||||
class=""
|
||||
@close="mobileMenuOpen = false"
|
||||
:open="mobileMenuOpen"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-10 bg-black/50 dark:bg-black/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Panel -->
|
||||
<DialogPanel
|
||||
class="fixed inset-y-0 left-0 z-20 w-full overflow-y-auto bg-white dark:bg-gray-900 px-6 py-6 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10 dark:sm:ring-white/10 flex flex-col"
|
||||
>
|
||||
<!-- Top: Logo + Close button -->
|
||||
<div class="flex items-end justify-between">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="-m-1.5 p-1.5"
|
||||
@click="mobileMenuOpen = false"
|
||||
aria-label="BDBU Home"
|
||||
>
|
||||
<span class="sr-only">Bobu</span>
|
||||
<img
|
||||
class="h-6 w-auto block dark:hidden"
|
||||
:src="LOGOS.RedGaro"
|
||||
alt="Bobu Logo Light"
|
||||
/>
|
||||
<img
|
||||
class="h-6 w-auto hidden dark:block"
|
||||
:src="LOGOS.WhiteGaro"
|
||||
alt="Bobu Logo Dark"
|
||||
/>
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="-m-2.5 rounded-md p-2.5 text-gray-700 dark:text-white"
|
||||
@click="mobileMenuOpen = false"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<XMarkIcon class="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Middle: navItems + HeaderActions -->
|
||||
<div class="mt-12 flow-root">
|
||||
<div class="-my-6 divide-y divide-gray-500/10 dark:divide-gray-700">
|
||||
<div class="space-y-2 py-6">
|
||||
<NuxtLink
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
:to="item.href"
|
||||
@click="mobileMenuOpen = false"
|
||||
class="-mx-3 flex items-center gap-x-4 rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="item.icon"
|
||||
class="h-4 w-4"
|
||||
:style="item.color ? { color: item.color } : {}"
|
||||
/>
|
||||
{{ item.name }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="py-6 space-y-2">
|
||||
<!-- Not logged in: show 로그인/회원가입 -->
|
||||
<NuxtLink
|
||||
v-if="!userStore.userLoggedIn"
|
||||
to="/login"
|
||||
class="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
로그인 / 회원가입
|
||||
</NuxtLink>
|
||||
|
||||
<!-- If logged in: show 마이페이지 -->
|
||||
<NuxtLink
|
||||
v-else
|
||||
to="/mypage"
|
||||
class="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
마이페이지
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Always show Cart when logged in -->
|
||||
<NuxtLink
|
||||
v-if="userStore.userLoggedIn"
|
||||
to="/shop"
|
||||
class="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7 text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
장바구니
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-grow"></div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="mt-6 flex items-end justify-between">
|
||||
<app-select-language />
|
||||
<ColorModeSelector />
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Dialog, DialogPanel, PopoverGroup } from '@headlessui/vue';
|
||||
import { Bars3Icon, XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
import { LOGOS } from '@/data/assets';
|
||||
import useUserStore from '@/stores/user';
|
||||
import HeaderActions from './header/HeaderAction.vue';
|
||||
import AppSelectLanguage from './SelectLanguage.vue';
|
||||
import { BASE_NAV_ITEMS } from '@/data/config';
|
||||
|
||||
const mobileMenuOpen = ref(false);
|
||||
const userStore = useUserStore();
|
||||
|
||||
// Navigation items definition
|
||||
const navItems = computed(() => {
|
||||
// Get the user role, default to 0 if logged out or role is undefined/null
|
||||
const currentRole = userStore.userRole || 0;
|
||||
|
||||
return BASE_NAV_ITEMS.filter((item) => {
|
||||
return !item.requiredRole || currentRole >= item.requiredRole;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any component-specific styles here if needed, beyond Tailwind */
|
||||
</style>
|
||||
26
bobu/app/components/NaverMap.vue
Normal file
26
bobu/app/components/NaverMap.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<!-- <img :src="staticUrl" alt="Loading map…" /> -->
|
||||
<div ref="mapEl" class="w-full h-96"></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
const mapEl = ref<HTMLDivElement | null>(null);
|
||||
const mapLoaded = ref(false);
|
||||
const staticUrl = `
|
||||
https://naveropenapi.apigw.ntruss.com/map-static/v2/raster-cors
|
||||
?w=600&h=400
|
||||
&markers=type:d%7Csize:mid%7Ccolor:blue%7Cpos:37.5008668%126.8880337
|
||||
&X-NCP-APIGW-API-KEY-ID=ocltl9lci4
|
||||
`.replace(/\s+/g, '');
|
||||
onMounted(() => {
|
||||
if (!window.naver?.maps) return;
|
||||
if (!mapEl.value) return;
|
||||
|
||||
const map = new window.naver.maps.Map(mapEl.value, {
|
||||
center: new window.naver.maps.LatLng(37.5008668, 126.8880337),
|
||||
zoom: 13,
|
||||
});
|
||||
new window.naver.maps.Marker({ position: map.getCenter(), map });
|
||||
});
|
||||
</script>
|
||||
114
bobu/app/components/SelectLanguage.vue
Normal file
114
bobu/app/components/SelectLanguage.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="w-40">
|
||||
<Combobox as="div" v-model="selected" @update:modelValue="query = ''">
|
||||
<div class="relative mt-1">
|
||||
<!-- Input now uses displayValue(lang) to include the flag -->
|
||||
<ComboboxInput
|
||||
class="w-full rounded-md bg-white dark:bg-gray-800 py-1.5 pr-10 pl-3 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
@input="query = $event.target.value"
|
||||
:display-value="(lang :any ) => displayValue(lang)"
|
||||
placeholder="Select..."
|
||||
/>
|
||||
|
||||
<ComboboxButton
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-2 text-gray-400"
|
||||
aria-label="Toggle language menu"
|
||||
>
|
||||
<ChevronUpDownIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</ComboboxButton>
|
||||
|
||||
<!-- Options panel above input -->
|
||||
<ComboboxOptions
|
||||
v-if="filteredLanguages.length > 0"
|
||||
class="absolute z-10 bottom-full mb-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-gray-800 py-1 shadow-lg ring-1 ring-black/10 focus:outline-none text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="lang in filteredLanguages"
|
||||
:key="lang.id"
|
||||
:value="lang"
|
||||
as="template"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9 flex items-center gap-x-2',
|
||||
active
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-900 dark:text-gray-100',
|
||||
]"
|
||||
>
|
||||
<span class="text-lg">{{ lang.flag }}</span>
|
||||
<span :class="['block truncate', selected && 'font-semibold']">
|
||||
{{ lang.label }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
active ? 'text-white' : 'text-indigo-600',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from '@headlessui/vue';
|
||||
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/vue/20/solid';
|
||||
|
||||
// 1) Language options now include a `flag` property
|
||||
const languageOptions = [
|
||||
{ id: 'ko', label: '한국어', flag: '🇰🇷' },
|
||||
{ id: 'en', label: 'English', flag: '🇺🇸' },
|
||||
];
|
||||
|
||||
// 2) vue-i18n hook
|
||||
const { locale } = useI18n();
|
||||
|
||||
// 3) Combobox state
|
||||
const query = ref('');
|
||||
const selected = ref(
|
||||
languageOptions.find((lang) => lang.id === locale.value) || languageOptions[0]
|
||||
);
|
||||
|
||||
// 4) Compute filtered list based on `query`
|
||||
const filteredLanguages = computed(() => {
|
||||
if (query.value === '') {
|
||||
return languageOptions;
|
||||
}
|
||||
return languageOptions.filter((lang) =>
|
||||
lang.label.toLowerCase().includes(query.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// 5) Whenever `selected` changes, update i18n’s locale
|
||||
watch(selected, (newLang) => {
|
||||
if (newLang && newLang.id) {
|
||||
locale.value = newLang.id as 'ko' | 'en';
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to display “🇰🇷 한국어” or “🇺🇸 English” in the input
|
||||
function displayValue(lang: { flag: string; label: string } | null) {
|
||||
return lang ? `${lang.flag} ${lang.label}` : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* No extra CSS needed—Tailwind handles positioning and styling */
|
||||
</style>
|
||||
282
bobu/app/components/WadizForm.vue
Normal file
282
bobu/app/components/WadizForm.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
>
|
||||
<form @submit.prevent="onSubmit" class="mx-auto mt-10 max-w-lg space-y-8">
|
||||
<!-- 1) 성함 -->
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
성함 (Your Name)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
id="name"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 2) 와디즈 결제 번호 -->
|
||||
<div>
|
||||
<label
|
||||
for="paymentId"
|
||||
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
와디즈 결제 번호 (Wadiz Payment ID)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.paymentId"
|
||||
type="text"
|
||||
id="paymentId"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 3) 메일 -->
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
메일 (Email)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
id="email"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 4) 본인 연락처 -->
|
||||
<div>
|
||||
<label
|
||||
for="phone"
|
||||
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
본인 연락처 (Your Phone)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
id="phone"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
|
||||
placeholder="010-1234-5678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 5) 본인 참석 여부 -->
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>본인 참석 여부 (Are you attending?)</span
|
||||
>
|
||||
<div class="mt-2 flex items-center gap-6">
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="attending"
|
||||
value="yes"
|
||||
v-model="form.attending"
|
||||
class="h-4 w-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
|
||||
/>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-300">예 (Yes)</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="attending"
|
||||
value="no"
|
||||
v-model="form.attending"
|
||||
class="h-4 w-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
|
||||
/>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-300">아니오 (No)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6) If “아니오”, show alternate attendee fields -->
|
||||
<transition name="fade" mode="out-in">
|
||||
<div v-if="form.attending === 'no'" class="space-y-4">
|
||||
<!-- 대리 참석자 성함 -->
|
||||
<div>
|
||||
<label
|
||||
for="altName"
|
||||
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
대리 참석자 성함 (Alternate Attendee Name)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.altName"
|
||||
type="text"
|
||||
id="altName"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
|
||||
/>
|
||||
</div>
|
||||
<!-- 대리 참석자 연락처 -->
|
||||
<div>
|
||||
<label
|
||||
for="altPhone"
|
||||
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
대리 참석자 연락처 (Alternate Attendee Phone)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.altPhone"
|
||||
type="tel"
|
||||
id="altPhone"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
|
||||
placeholder="010-1234-5678"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 7) 기타 문의 사항 -->
|
||||
<div>
|
||||
<label
|
||||
for="remarks"
|
||||
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
기타 문의 사항 (Other Remarks)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.remarks"
|
||||
id="remarks"
|
||||
rows="4"
|
||||
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
|
||||
placeholder="예: 채팅으로 연락해주세요."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="inline-flex w-full justify-center rounded-md bg-indigo-600 dark:bg-indigo-500 px-4 py-2 text-base font-semibold text-white shadow-sm hover:bg-indigo-500 dark:hover:bg-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-600 disabled:opacity-50"
|
||||
>
|
||||
{{ submitting ? '전송 중…' : '제출하기' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Success / Error Message -->
|
||||
<p
|
||||
v-if="message"
|
||||
:class="
|
||||
messageError
|
||||
? 'mt-4 text-sm text-red-600'
|
||||
: 'mt-4 text-sm text-green-600'
|
||||
"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useNuxtApp } from '#app';
|
||||
|
||||
/**
|
||||
* This form will write a document to Firestore collection "wadizSubmissions".
|
||||
* Make sure you've set up a Nuxt plugin that provides `$firebase.firestore()` if you haven't already.
|
||||
*/
|
||||
|
||||
const submitting = ref(false);
|
||||
const message = ref('');
|
||||
const messageError = ref(false);
|
||||
|
||||
// Form state
|
||||
const form = ref({
|
||||
name: '',
|
||||
paymentId: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
attending: 'yes', // default “yes”
|
||||
altName: '',
|
||||
altPhone: '',
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
submitting.value = true;
|
||||
message.value = '';
|
||||
messageError.value = false;
|
||||
|
||||
// Basic validation
|
||||
if (
|
||||
!form.value.name ||
|
||||
!form.value.paymentId ||
|
||||
!form.value.email ||
|
||||
!form.value.phone
|
||||
) {
|
||||
message.value = '필수 항목을 모두 채워주세요.';
|
||||
messageError.value = true;
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
form.value.attending === 'no' &&
|
||||
(!form.value.altName || !form.value.altPhone)
|
||||
) {
|
||||
message.value = '대리 참석자 정보를 모두 입력해주세요.';
|
||||
messageError.value = true;
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare payload
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
paymentId: form.value.paymentId,
|
||||
email: form.value.email,
|
||||
phone: form.value.phone,
|
||||
attending: form.value.attending === 'yes',
|
||||
altName: form.value.attending === 'no' ? form.value.altName : null,
|
||||
altPhone: form.value.attending === 'no' ? form.value.altPhone : null,
|
||||
remarks: form.value.remarks,
|
||||
submittedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
// Save to Firestore (collection “wadizSubmissions”)
|
||||
message.value = '성공적으로 제출되었습니다!';
|
||||
messageError.value = false;
|
||||
|
||||
// Clear form
|
||||
form.value.name = '';
|
||||
form.value.paymentId = '';
|
||||
form.value.email = '';
|
||||
form.value.phone = '';
|
||||
form.value.attending = 'yes';
|
||||
form.value.altName = '';
|
||||
form.value.altPhone = '';
|
||||
form.value.remarks = '';
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.value = '제출 중 오류가 발생했습니다. 다시 시도해주세요.';
|
||||
messageError.value = true;
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Fade transition for the conditional fields */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
94
bobu/app/components/about/AboutSection1.vue
Normal file
94
bobu/app/components/about/AboutSection1.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<section class="bg-white dark:bg-gray-900 py-16 sm:py-24">
|
||||
<div class="mx-auto max-w-3xl px-4 text-center">
|
||||
<!-- Title -->
|
||||
<h2
|
||||
class="text-3xl font-bold text-gray-900 dark:text-gray-100 sm:text-4xl"
|
||||
>
|
||||
노마드소셜클럽 보부<br />
|
||||
<span class="text-xl font-medium text-gray-600 dark:text-gray-300"
|
||||
>Nomad Social Club BOBU</span
|
||||
>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Image Grid -->
|
||||
<div
|
||||
class="mt-12 mx-auto grid max-w-4xl grid-cols-2 gap-6 px-4 sm:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="(imgSrc, idx) in images"
|
||||
:key="idx"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<img
|
||||
:src="imgSrc"
|
||||
alt="About image"
|
||||
class="h-32 w-32 rounded-full object-cover shadow-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Text -->
|
||||
<div
|
||||
class="mt-12 mx-auto max-w-2xl px-4 text-center text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<p class="whitespace-pre-line leading-relaxed">
|
||||
강원도 정선군 정선읍 봉양리, 작은 마을에 자리한 워케이션 오피스입니다.
|
||||
다양한 방식으로 일하는 창작자들을 위해 공유 오피스, 로컬 콘텐츠,
|
||||
투어(워크숍) 등을 운영하며 일과 일상, 여행이 자연스럽게 이어지는 유연한
|
||||
삶의 방식을 제안합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Link Buttons with Logos -->
|
||||
<div class="mt-8 flex justify-center space-x-6 px-4">
|
||||
<!-- 네이버플레이스 -->
|
||||
<a
|
||||
:href="socialLinks.naver"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center space-x-2 px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
<img
|
||||
:src="socialImages.naver"
|
||||
alt="네이버플레이스 로고"
|
||||
class="h-6 w-6 object-contain"
|
||||
/>
|
||||
<span>네이버플레이스</span>
|
||||
</a>
|
||||
|
||||
<!-- 인스타그램 -->
|
||||
<a
|
||||
:href="socialLinks.instagram"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center space-x-2 px-4 py-2 bg-pink-500 text-white font-semibold rounded-lg hover:bg-pink-600 transition"
|
||||
>
|
||||
<img
|
||||
:src="socialImages.instagram"
|
||||
alt="인스타그램 로고"
|
||||
class="h-6 w-6 object-contain"
|
||||
/>
|
||||
<span>인스타그램</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { MAIN2_IMAGES, SOCIAL_IMAGES } from '@/data/assets';
|
||||
import { SOCIAL_LINKS } from '@/data/config';
|
||||
|
||||
// 1) MAIN2_IMAGES → 배열
|
||||
const images = computed<string[]>(() => Object.values(MAIN2_IMAGES));
|
||||
|
||||
// 2) SOCIAL_IMAGES (로고)와 SOCIAL_LINKS (URL) 노출
|
||||
const socialImages = SOCIAL_IMAGES;
|
||||
const socialLinks = SOCIAL_LINKS;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Tailwind 유틸리티만으로 충분하므로 추가 CSS 불필요 */
|
||||
</style>
|
||||
97
bobu/app/components/about/AboutSection2.vue
Normal file
97
bobu/app/components/about/AboutSection2.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<section class="bg-white dark:bg-gray-900 py-24 sm:py-32">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div
|
||||
class="mx-auto grid max-w-2xl grid-cols-1 items-start gap-x-8 gap-y-16 sm:gap-y-24 lg:mx-0 lg:max-w-none lg:grid-cols-2"
|
||||
>
|
||||
<!-- Quote + Image Card -->
|
||||
<div class="lg:pr-4">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-3xl bg-gray-900 px-6 pt-64 pb-9 shadow-2xl sm:px-12 lg:max-w-lg lg:px-8 lg:pb-8 xl:px-10 xl:pb-10"
|
||||
>
|
||||
<img
|
||||
class="absolute inset-0 w-full h-full object-cover brightness-105 saturate-100"
|
||||
src="/assets/img/shoot.jpg"
|
||||
alt="Bobu team visual"
|
||||
/>
|
||||
<!-- dark overlay ↔ lighten in light mode -->
|
||||
<div class="absolute inset-0 bg-black/40 dark:bg-black/60" />
|
||||
<figure class="relative isolate">
|
||||
<blockquote class="mt-6 text-xl font-semibold text-white">
|
||||
<p>
|
||||
{{ $t('about.quote') }}
|
||||
</p>
|
||||
</blockquote>
|
||||
<figcaption class="mt-6 text-sm text-gray-300">
|
||||
<strong class="font-semibold text-white">CEO,</strong>
|
||||
{{ $t('about.ceo') }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Block -->
|
||||
<div>
|
||||
<div class="text-base text-gray-700 dark:text-gray-300 lg:max-w-lg">
|
||||
<p
|
||||
class="text-base font-semibold text-indigo-600 dark:text-indigo-400"
|
||||
>
|
||||
{{ $t('about.sectionLabel') }}
|
||||
</p>
|
||||
<h1
|
||||
class="mt-2 text-4xl font-semibold tracking-tight text-gray-900 dark:text-white sm:text-5xl"
|
||||
>
|
||||
{{ $t('about.sectionTitle') }}
|
||||
</h1>
|
||||
|
||||
<div class="max-w-xl">
|
||||
<p class="mt-6">{{ $t('about.mission') }}</p>
|
||||
<p class="mt-8">{{ $t('about.vision') }}</p>
|
||||
<p class="mt-8">{{ $t('about.portfolio') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<dl
|
||||
class="mt-10 grid grid-cols-2 gap-8 border-t border-gray-900/10 dark:border-gray-100/10 pt-10 sm:grid-cols-4"
|
||||
>
|
||||
<div v-for="(stat, statIdx) in stats" :key="statIdx">
|
||||
<dt
|
||||
class="text-sm font-semibold text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{{ stat.label }}
|
||||
</dt>
|
||||
<dd
|
||||
class="mt-2 text-3xl font-bold tracking-tight text-gray-900 dark:text-white"
|
||||
>
|
||||
{{ stat.value }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="mt-10 flex">
|
||||
<NuxtLink
|
||||
to="/projects"
|
||||
class="text-base font-semibold text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||
>
|
||||
{{ $t('about.cta') }} <span aria-hidden="true">→</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const stats = [
|
||||
{ label: 'Founded', value: '2018' },
|
||||
{ label: 'Projects Delivered', value: '120+' },
|
||||
{ label: 'Technologies', value: 'FILM / XR / 3D' },
|
||||
{ label: 'Countries', value: '3' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Tailwind utilities handle all dark/light styling */
|
||||
</style>
|
||||
94
bobu/app/components/about/AboutSection3.vue
Normal file
94
bobu/app/components/about/AboutSection3.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<section class="bg-white dark:bg-gray-900 py-24 sm:py-32">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl text-center">
|
||||
<h2
|
||||
class="text-3xl font-bold text-gray-900 dark:text-gray-100 sm:text-4xl"
|
||||
>
|
||||
지난 프로그램 또는 후기
|
||||
</h2>
|
||||
<p class="mt-2 text-lg/8 text-gray-600 dark:text-gray-400">
|
||||
Learn how to grow your business with our expert advice.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="mx-auto mt-16 grid max-w-2xl auto-rows-fr grid-cols-1 gap-8 sm:mt-20 lg:mx-0 lg:max-w-none lg:grid-cols-3"
|
||||
>
|
||||
<article
|
||||
v-for="post in posts"
|
||||
:key="post.id"
|
||||
class="relative flex flex-col justify-end overflow-hidden rounded-2xl"
|
||||
>
|
||||
<!-- Background image -->
|
||||
<img
|
||||
:src="post.imageUrl"
|
||||
alt=""
|
||||
class="absolute inset-0 w-full h-full object-cover z-0"
|
||||
/>
|
||||
<!-- 20% black overlay in light, 50% in dark -->
|
||||
<div class="absolute inset-0 bg-black/20 dark:bg-black/50 z-10"></div>
|
||||
<!-- Gradient on top of overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent z-20"
|
||||
></div>
|
||||
<!-- Content -->
|
||||
<div class="relative z-30 px-8 pb-8 pt-48 sm:pt-32 lg:pt-48">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-y-1 overflow-hidden text-sm/6 text-gray-300"
|
||||
>
|
||||
<time :datetime="post.datetime" class="mr-8">{{
|
||||
post.date
|
||||
}}</time>
|
||||
<div class="-ml-4 flex items-center gap-x-4">
|
||||
<svg
|
||||
viewBox="0 0 2 2"
|
||||
class="-ml-0.5 h-2 w-2 flex-none fill-white/50"
|
||||
>
|
||||
<circle cx="1" cy="1" r="1" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="mt-3 text-lg/6 font-semibold text-white">
|
||||
<a :href="post.href" class="relative block">
|
||||
<span class="absolute inset-0" />
|
||||
{{ post.title }}
|
||||
</a>
|
||||
</h3>
|
||||
<p
|
||||
class="mt-2 text-sm text-gray-200 dark:text-gray-300 line-clamp-2"
|
||||
>
|
||||
{{ post.description }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const posts = [
|
||||
{
|
||||
id: 1,
|
||||
title: '와디즈',
|
||||
href: '#',
|
||||
description:
|
||||
'Illo sint voluptas. Error voluptates culpa eligendi. Hic vel totam vitae illo. Non aliquid explicabo necessitatibus unde. Sed exercitationem placeat consectetur nulla deserunt vel. Iusto corrupti dicta.',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1496128858413-b36217c2ce36?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3603&q=80',
|
||||
date: 'Mar 16, 2020',
|
||||
datetime: '2020-03-16',
|
||||
author: {
|
||||
name: 'Michael Foster',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
Ensure you have the Tailwind line-clamp plugin enabled for “line-clamp-2”.
|
||||
*/
|
||||
</style>
|
||||
53
bobu/app/components/about/AboutSection4.vue
Normal file
53
bobu/app/components/about/AboutSection4.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<section
|
||||
class="bg-white dark:bg-gray-900 pt-24 pb-16 sm:pt-32 sm:pb-24 xl:pb-32"
|
||||
>
|
||||
<div class="bg-gray-900 pb-20 sm:pb-24 xl:pb-0">
|
||||
<div
|
||||
class="mx-auto flex max-w-7xl flex-col items-center gap-x-8 gap-y-10 px-6 sm:gap-y-8 lg:px-8 xl:flex-row xl:items-stretch"
|
||||
>
|
||||
<!-- CEO Image -->
|
||||
<div class="-mt-8 w-full max-w-2xl xl:-mb-8 xl:w-96 xl:flex-none">
|
||||
<div
|
||||
class="relative aspect-2/1 h-full md:-mx-8 xl:mx-0 xl:aspect-auto"
|
||||
>
|
||||
<img
|
||||
class="absolute inset-0 size-full rounded-2xl bg-gray-800 object-cover shadow-2xl"
|
||||
:src="ABOUT_IMAGES.ceoQuote"
|
||||
alt="CEO or creative leadership"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CEO Quote -->
|
||||
<div
|
||||
class="w-full max-w-2xl xl:max-w-none xl:flex-auto xl:px-16 xl:py-24"
|
||||
>
|
||||
<figure class="relative isolate pt-6 sm:pt-12">
|
||||
<svg
|
||||
viewBox="0 0 162 128"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
class="absolute top-0 left-0 -z-10 h-32 stroke-white/20"
|
||||
></svg>
|
||||
<blockquote
|
||||
class="text-xl font-semibold text-white sm:text-2xl leading-relaxed"
|
||||
>
|
||||
<p>
|
||||
{{ $t('about.ceoQuote') }}
|
||||
</p>
|
||||
</blockquote>
|
||||
<figcaption class="mt-8 text-base">
|
||||
<div class="font-semibold text-white">{{ $t('about.ceo') }}</div>
|
||||
<div class="mt-1 text-gray-400">{{ $t('about.ceoTitle') }}</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ABOUT_IMAGES } from '@/data/assets';
|
||||
</script>
|
||||
142
bobu/app/components/auth/admin-login.vue
Normal file
142
bobu/app/components/auth/admin-login.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8"
|
||||
>
|
||||
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<img
|
||||
class="mx-auto h-10 w-auto"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade-600"
|
||||
alt="Your Company"
|
||||
/>
|
||||
<h2
|
||||
class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-gray-900 dark:text-white"
|
||||
>
|
||||
Sign in to your account
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
class="block w-full rounded-md bg-white dark:bg-white/5 px-3 py-1.5 text-base text-gray-900 dark:text-white outline-1 -outline-offset-1 outline-gray-300 dark:outline-white/10 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:focus:outline-indigo-500 sm:text-sm/6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label
|
||||
for="password"
|
||||
class="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="block w-full rounded-md bg-white dark:bg-white/5 px-3 py-1.5 text-base text-gray-900 dark:text-white outline-1 -outline-offset-1 outline-gray-300 dark:outline-white/10 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:focus:outline-indigo-500 sm:text-sm/6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert message -->
|
||||
<div
|
||||
v-if="login_show_alert"
|
||||
:class="[
|
||||
'text-white text-center font-bold p-4 mb-4',
|
||||
login_alert_variant,
|
||||
]"
|
||||
>
|
||||
{{ login_alert_msg }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="login_in_submission"
|
||||
class="flex w-full justify-center rounded-md bg-indigo-600 dark:bg-indigo-500 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-xs hover:bg-indigo-500 dark:hover:bg-indigo-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="mt-10 text-center text-sm/6 text-gray-500 dark:text-gray-400">
|
||||
For You master,
|
||||
<a
|
||||
href="#"
|
||||
class="font-semibold text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300"
|
||||
>
|
||||
Get it Sign up
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import useUserStore from '@/stores/user';
|
||||
|
||||
import type { LoginValues } from '@/types';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const login_in_submission = ref(false);
|
||||
const login_show_alert = ref(false);
|
||||
const login_alert_variant = ref('bg-blue-500'); // fixed typo
|
||||
const login_alert_msg = ref('로그인 중입니다. 잠시만 기다려주세요!');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const values: LoginValues = {
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
};
|
||||
|
||||
login_in_submission.value = true;
|
||||
login_show_alert.value = true;
|
||||
login_alert_variant.value = 'bg-blue-500';
|
||||
login_alert_msg.value = '로그인 중입니다. 잠시만 기다려주세요!';
|
||||
|
||||
if (!values.email.includes('@')) {
|
||||
values.email += '@peer-edu.net';
|
||||
}
|
||||
|
||||
try {
|
||||
await userStore.authenticate(values);
|
||||
} catch (error) {
|
||||
login_in_submission.value = false;
|
||||
login_alert_variant.value = 'bg-red-500';
|
||||
login_alert_msg.value = '아이디 또는 비밀번호가 일치하지 않습니다.';
|
||||
return;
|
||||
}
|
||||
|
||||
login_alert_variant.value = 'bg-green-500';
|
||||
login_alert_msg.value = '로그인에 성공하였습니다!';
|
||||
const router = useRouter();
|
||||
router.push('/');
|
||||
};
|
||||
</script>
|
||||
57
bobu/app/components/boards/BoardAction.vue
Normal file
57
bobu/app/components/boards/BoardAction.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="flex" v-if="userRole > 4">
|
||||
<ClientOnly>
|
||||
<span class="ml-auto inline-flex rounded-md shadow-sm dark:shadow-md m-4">
|
||||
<NuxtLink
|
||||
type="button"
|
||||
:to="listRouteName"
|
||||
class="relative inline-flex items-center rounded-l-md px-3 py-2 text-sm font-semibold bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10 dark:bg-gray-700 dark:text-gray-200 dark:ring-gray-600 dark:hover:bg-gray-600"
|
||||
>
|
||||
목록
|
||||
</NuxtLink>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.prevent="emit('edit')"
|
||||
class="relative -ml-px inline-flex items-center px-3 py-2 text-sm font-semibold bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10 dark:bg-gray-700 dark:text-gray-200 dark:ring-gray-600 dark:hover:bg-gray-600"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click.prevent="emit('delete')"
|
||||
class="relative -ml-px inline-flex items-center px-3 py-2 text-sm font-semibold bg-red-600 text-white ring-1 ring-inset ring-red-700 hover:bg-red-700 focus:z-10 dark:bg-red-700 dark:ring-red-800 dark:hover:bg-red-800 dark:text-red-100"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
|
||||
<NuxtLink
|
||||
type="button"
|
||||
:to="uploadRouteName"
|
||||
class="relative -ml-px inline-flex items-center rounded-r-md px-3 py-2 text-sm font-semibold bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10 dark:bg-gray-700 dark:text-gray-200 dark:ring-gray-600 dark:hover:bg-gray-600"
|
||||
>
|
||||
글쓰기
|
||||
</NuxtLink>
|
||||
</span>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import useUserStore from '@/stores/user';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userRole = computed(() => userStore.userRole);
|
||||
|
||||
const props = defineProps<{
|
||||
listRouteName: string;
|
||||
uploadRouteName: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'edit'): void;
|
||||
(e: 'delete'): void;
|
||||
}>();
|
||||
</script>
|
||||
75
bobu/app/components/boards/BoardBody.vue
Normal file
75
bobu/app/components/boards/BoardBody.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="board"
|
||||
class="border rounded-lg dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
<div class="board md:flex flex-wrap border-b dark:border-gray-700">
|
||||
<div
|
||||
class="py-2 px-4 bg-stone-100 md:text-center font-semibold w-full md:w-1/5 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
제목
|
||||
</div>
|
||||
<div class="px-4 py-2 flex-1 dark:text-gray-200">{{ board.title }}</div>
|
||||
</div>
|
||||
|
||||
<div class="board md:flex flex-wrap border-b dark:border-gray-700">
|
||||
<div
|
||||
class="py-2 px-4 bg-stone-100 flex md:justify-center md:items-center font-semibold w-full md:w-1/5 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
첨부파일
|
||||
</div>
|
||||
<div class="flex flex-col px-4 py-2 flex-1">
|
||||
<template v-if="board.files && board.files.length">
|
||||
<a
|
||||
v-for="(file, index) in board.files"
|
||||
:key="index"
|
||||
:href="file.url"
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 py-1 transition-colors duration-150"
|
||||
download
|
||||
>
|
||||
<i class="fas fa-download mr-1 fa-fw"></i> {{ file.name }}
|
||||
</a>
|
||||
</template>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">
|
||||
첨부파일이 없습니다.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="board">
|
||||
<div class="px-4 py-6">
|
||||
<div
|
||||
class="flex flex-wrap justify-center items-center mb-6"
|
||||
v-if="board.thumbnail?.url"
|
||||
>
|
||||
<img
|
||||
:src="board.thumbnail.url"
|
||||
:alt="board.thumbnail.name || 'Thumbnail'"
|
||||
class="max-w-full h-auto rounded shadow-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="prose prose-stone max-w-none dark:prose-invert dark:text-gray-300"
|
||||
v-html="board.description"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BoardItem } from '@/types';
|
||||
|
||||
// Define component props
|
||||
const props = defineProps<{
|
||||
board: BoardItem;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
36
bobu/app/components/boards/BoardHeader.vue
Normal file
36
bobu/app/components/boards/BoardHeader.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-gradient-to-r from-indigo-50 to-white dark:from-gray-900 dark:to-gray-950 pt-36 pb-16 sm:pt-36 sm:pb-20"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl lg:mx-0">
|
||||
<p
|
||||
class="text-medium font-semibold leading-7 text-indigo-600 dark:text-indigo-400"
|
||||
>
|
||||
{{ h3data }}
|
||||
</p>
|
||||
<h2
|
||||
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-5xl"
|
||||
>
|
||||
{{ h2data }}
|
||||
</h2>
|
||||
<p class="mt-2 text-medium leading-8 text-gray-600 dark:text-gray-300">
|
||||
{{ h1data }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Props definition - no changes needed here
|
||||
const {
|
||||
h1data = 'SOCIAL SERVICE | FILM PRODUCTION | DEVELOPMENT',
|
||||
h2data = 'SubHeader Title | SubHeader Title',
|
||||
h3data = 'SOCIAL COOPERATIVE',
|
||||
} = defineProps<{
|
||||
h1data?: string;
|
||||
h2data?: string;
|
||||
h3data?: string;
|
||||
}>();
|
||||
</script>
|
||||
202
bobu/app/components/boards/BoardList.vue
Normal file
202
bobu/app/components/boards/BoardList.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<section
|
||||
class="bg-white dark:bg-gray-900 flex w-full justify-center min-h-[85vh] mt-20 mb-50"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-6 w-full py-4 items-center">
|
||||
<!-- Header & Sort -->
|
||||
<div
|
||||
class="flex justify-between pt-6 pb-4 font-bold border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<span class="text-xl card-title text-gray-900 dark:text-white">{{
|
||||
title
|
||||
}}</span>
|
||||
|
||||
<!-- Sort Dropdown -->
|
||||
<Listbox
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="emit('update:modelValue', $event)"
|
||||
>
|
||||
<ListboxLabel
|
||||
class="block text-sm font-medium leading-6 text-gray-900 dark:text-white"
|
||||
/>
|
||||
<div class="relative">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-white dark:bg-gray-800 py-1.5 pl-3 pr-10 text-left text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<span class="block truncate">{{ selectedSort }}</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white dark:bg-gray-800 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
as="div"
|
||||
v-for="sortOption in sortOptions"
|
||||
:key="sortOption.sortId"
|
||||
:value="sortOption"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-gray-900 dark:text-white',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>
|
||||
{{ sortOption.name }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-indigo-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<!-- List Items -->
|
||||
<div>
|
||||
<LoadingSection
|
||||
:isLoading="isLoading"
|
||||
:loadingMessage="loadingMessage"
|
||||
/>
|
||||
</div>
|
||||
<ol>
|
||||
<slot name="list" />
|
||||
</ol>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex" v-if="userRole > 4">
|
||||
<span class="ml-auto inline-flex rounded-md shadow-sm m-4">
|
||||
<!-- Toggle Delete Mode -->
|
||||
<button
|
||||
v-if="!showSelectBoxes"
|
||||
@click.prevent="emit('toggle-select-boxes')"
|
||||
class="relative -ml-px inline-flex items-center bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-10"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
|
||||
<!-- Confirm Delete -->
|
||||
<button
|
||||
v-else
|
||||
@click.prevent="emit('delete-selected')"
|
||||
class="relative -ml-px inline-flex items-center bg-red-500 px-3 py-2 text-sm font-semibold text-white ring-1 ring-inset ring-gray-300 hover:bg-red-700 focus:z-10"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<NuxtLink
|
||||
:to="uploadRoute"
|
||||
class="relative -ml-px inline-flex items-center rounded-r-md bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-10"
|
||||
>
|
||||
글쓰기
|
||||
</NuxtLink>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<nav class="flex mx-4 mt-8 p-3 sm:px-0">
|
||||
<div class="flex-grow md:flex-none w-24 flex justify-center">
|
||||
<button
|
||||
:disabled="currentPage === 1"
|
||||
@click="emit('prev-page')"
|
||||
class="inline-flex items-center border-b-2 border-transparent pr-1 py-4 text-base font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:hover:text-gray-300 disabled:opacity-50"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'angle-left']" /> 이전
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex-grow w-20 hidden md:flex md:flex-auto md:w-10 md:justify-center"
|
||||
>
|
||||
<div v-for="pageNumber in pageNumbers" :key="pageNumber">
|
||||
<button
|
||||
class="inline-flex items-center border-b-2 border-transparent px-4 py-4 text-base font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:hover:text-gray-300 group"
|
||||
:class="{
|
||||
'font-bold border-indigo-500 text-indigo-600':
|
||||
currentPage === pageNumber,
|
||||
}"
|
||||
:disabled="currentPage === pageNumber"
|
||||
@click="emit('go-to-page', pageNumber)"
|
||||
>
|
||||
{{ pageNumber }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow md:flex-none w-24 flex justify-center">
|
||||
<button
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="emit('next-page')"
|
||||
class="inline-flex items-center border-b-2 border-transparent pr-1 py-4 text-base font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:hover:text-gray-300 disabled:opacity-50"
|
||||
>
|
||||
다음 <font-awesome-icon :icon="['fas', 'angle-right']" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from '@headlessui/vue';
|
||||
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/vue/20/solid';
|
||||
|
||||
import type { SortOption } from '@/types';
|
||||
import { computed } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
modelValue: SortOption;
|
||||
sortOptions: SortOption[];
|
||||
userRole: number;
|
||||
showSelectBoxes: boolean;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
pageNumbers: number[];
|
||||
uploadRoute: string;
|
||||
isLoading: boolean;
|
||||
loadingMessage: string;
|
||||
}>();
|
||||
|
||||
const selectedSort = computed(() => props.modelValue.name);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: SortOption): void;
|
||||
(e: 'toggle-select-boxes'): void;
|
||||
(e: 'delete-selected'): void;
|
||||
(e: 'go-to-page', pageNumber: number): void;
|
||||
(e: 'prev-page'): void;
|
||||
(e: 'next-page'): void;
|
||||
}>();
|
||||
</script>
|
||||
156
bobu/app/components/boards/BoardListSingle.vue
Normal file
156
bobu/app/components/boards/BoardListSingle.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div>
|
||||
<li
|
||||
class="py-3 transition duration-300 hover:bg-gray-50 dark:hover:bg-gray-800 items-start border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex flex-col mx-4 justify-center md:flex-row text-normal">
|
||||
<!-- Announcement icon or board number -->
|
||||
<div
|
||||
v-if="!showSelectBox"
|
||||
class="pr-4 md:pr-8 text-gray-400 hidden md:block"
|
||||
>
|
||||
<template v-if="item.announcement">
|
||||
<font-awesome-icon :icon="iconName" fade style="color: #990000" />
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ item.boards_number }}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Selection checkbox -->
|
||||
<label class="flex items-center space-x-2">
|
||||
<input
|
||||
v-if="showSelectBox"
|
||||
type="checkbox"
|
||||
:checked="selected"
|
||||
@change="selectItem"
|
||||
class="mr-8 inline-block text-gray-600 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Title link -->
|
||||
<NuxtLink
|
||||
:to="`${routeName}/${item.docId}`"
|
||||
class="cursor-pointer flex-1 pr-8 inline-block text-gray-600 dark:text-white hover:underline"
|
||||
>
|
||||
{{ truncateTitle(item.title) }}
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Display author only for Manager+ -->
|
||||
<div
|
||||
v-if="userRole >= ROLE_THRESHOLD.MANAGER"
|
||||
class="text-xs text-gray-400 flex items-center pr-4"
|
||||
>
|
||||
{{ userDisplayName }}
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div class="inline-block text-gray-400 text-left md:hidden">
|
||||
{{ formatDate(item.created) }}
|
||||
</div>
|
||||
<div class="hidden md:inline-block text-gray-400 text-left">
|
||||
{{ formatDate2(item.created) }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue';
|
||||
import { fetchUserDisplayName } from '@/utils/boardUtils';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { ROLE_THRESHOLD } from '@/data/config';
|
||||
import type { BoardItem } from '@/types';
|
||||
|
||||
// Helper to normalize Firestore timestamp or other formats into JS Date
|
||||
function toDate(raw: unknown): Date {
|
||||
if (typeof raw === 'string') {
|
||||
return new Date(raw);
|
||||
}
|
||||
if (
|
||||
raw &&
|
||||
typeof raw === 'object' &&
|
||||
'toDate' in (raw as any) &&
|
||||
typeof (raw as any).toDate === 'function'
|
||||
) {
|
||||
return (raw as any).toDate();
|
||||
}
|
||||
const sec = (raw as any)?.seconds ?? (raw as any)?._seconds;
|
||||
if (typeof sec === 'number') {
|
||||
return new Date(sec * 1000);
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
item: BoardItem;
|
||||
showSelectBox?: boolean;
|
||||
selected?: boolean;
|
||||
iconName?: [string, string];
|
||||
routeName: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', item: BoardItem): void;
|
||||
}>();
|
||||
|
||||
// Reactive state
|
||||
const userStore = useUserStore();
|
||||
const userRole = computed(() => userStore.userRole);
|
||||
const userDisplayName = ref('');
|
||||
|
||||
// Title truncation length
|
||||
const truncatedLength = ref(6);
|
||||
|
||||
// Date formatting
|
||||
const formatDate = (raw: unknown) => {
|
||||
const d = toDate(raw);
|
||||
const YYYY = d.getFullYear();
|
||||
const MM = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const DD = String(d.getDate()).padStart(2, '0');
|
||||
return `${YYYY}-${MM}-${DD}`;
|
||||
};
|
||||
const formatDate2 = (raw: unknown) => {
|
||||
const d = toDate(raw);
|
||||
const MM = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const DD = String(d.getDate()).padStart(2, '0');
|
||||
return `${MM}-${DD}`;
|
||||
};
|
||||
|
||||
// Title truncation based on screen width
|
||||
const updateTruncatedLength = () => {
|
||||
const w = window.innerWidth / 14;
|
||||
truncatedLength.value = Math.round(Math.min(w, 60));
|
||||
};
|
||||
onMounted(() => {
|
||||
updateTruncatedLength();
|
||||
window.addEventListener('resize', updateTruncatedLength);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateTruncatedLength);
|
||||
});
|
||||
const truncateTitle = (t: string) =>
|
||||
t.length > truncatedLength.value
|
||||
? t.slice(0, truncatedLength.value) + '…'
|
||||
: t;
|
||||
|
||||
// Fetch user display name for manager+
|
||||
watch(
|
||||
() => props.item.userId,
|
||||
async (uid) => {
|
||||
if (uid && userRole.value >= ROLE_THRESHOLD.MANAGER) {
|
||||
userDisplayName.value = await fetchUserDisplayName(uid);
|
||||
} else {
|
||||
userDisplayName.value = uid || '';
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Selection emit
|
||||
const selectItem = () => {
|
||||
emit('select', props.item);
|
||||
};
|
||||
</script>
|
||||
503
bobu/app/components/boards/notice/UploadNoticeForm.vue
Normal file
503
bobu/app/components/boards/notice/UploadNoticeForm.vue
Normal file
@@ -0,0 +1,503 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-board-header
|
||||
:h2data="compData.title"
|
||||
v-if="!isEdit"
|
||||
></app-board-header>
|
||||
|
||||
<section
|
||||
class="bg-white dark:bg-gray-900 py-10 w-full min-h-[85vh] mt-20 mb-52"
|
||||
>
|
||||
<div class="mx-auto max-w-5xl w-full py-4 px-6 items-center">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-8">
|
||||
<VeeForm @submit="handleUpload" v-slot="{ isSubmitting }">
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="title"
|
||||
class="inline-block mb-2 font-bold dark:text-gray-200"
|
||||
>제목</label
|
||||
>>
|
||||
<VeeField
|
||||
name="title"
|
||||
type="text"
|
||||
rules="required|min:3"
|
||||
v-model="boardsData.title"
|
||||
class="block w-full py-1.5 px-3 border rounded text-gray-800 border-gray-300 focus:outline-none focus:border-indigo-500 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 dark:focus:border-indigo-500"
|
||||
placeholder="제목을 입력하세요"
|
||||
/>
|
||||
<VeeErrorMessage
|
||||
name="title"
|
||||
class="text-red-500 dark:text-red-400 text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap my-4">
|
||||
<div class="mb-3 mr-8">
|
||||
<Switch
|
||||
id="announcement"
|
||||
v-model="boardsData.announcement"
|
||||
class="align-middle"
|
||||
:class="[
|
||||
boardsData.announcement
|
||||
? 'bg-indigo-600 dark:bg-indigo-500'
|
||||
: 'bg-gray-200 dark:bg-gray-600', // Dark mode BG colors
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900', // Dark focus ring offset
|
||||
]"
|
||||
>
|
||||
<span class="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
boardsData.announcement
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out', // Knob bg-white works for both modes
|
||||
]"
|
||||
/>
|
||||
</Switch>
|
||||
<label
|
||||
for="announcement"
|
||||
class="inline-block ml-2 align-middle dark:text-gray-200"
|
||||
>공지글로 설정</label
|
||||
>>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<Switch
|
||||
v-model="boardsData.ishidden"
|
||||
id="ishidden"
|
||||
class="align-middle"
|
||||
:class="[
|
||||
boardsData.ishidden
|
||||
? 'bg-indigo-600 dark:bg-indigo-500'
|
||||
: 'bg-gray-200 dark:bg-gray-600', // Dark mode BG colors
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900', // Dark focus ring offset
|
||||
]"
|
||||
>
|
||||
<span class="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
boardsData.ishidden ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out', // Knob bg-white works for both modes
|
||||
]"
|
||||
/>
|
||||
</Switch>
|
||||
<label
|
||||
for="ishidden"
|
||||
class="inline-block ml-2 align-middle dark:text-gray-200"
|
||||
>비밀글로 설정</label
|
||||
>>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 my-4">
|
||||
<label
|
||||
for="thumbnail"
|
||||
class="inline-block font-bold dark:text-gray-200"
|
||||
>이미지 (본문과 함께표시)</label
|
||||
>>
|
||||
|
||||
<input
|
||||
ref="thumbnailInput"
|
||||
v-show="
|
||||
!thumbnail.preview.value &&
|
||||
!(
|
||||
thumbnail.oldPreviewState.value && boardsData.thumbnail?.url
|
||||
)
|
||||
"
|
||||
type="file"
|
||||
accept=".jpg, .jpeg, .png, .webp"
|
||||
@change="(e) => onThumbnailChange(e, thumbnail)"
|
||||
class="block w-full py-1.5 px-3 border border-gray-300 rounded mt-2 text-sm text-gray-900 dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 cursor-pointer focus:outline-none dark:placeholder-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100 dark:file:bg-indigo-700 dark:file:text-indigo-100 dark:hover:file:bg-indigo-600"
|
||||
/>
|
||||
<div
|
||||
class="py-2"
|
||||
v-if="
|
||||
thumbnail.preview.value ||
|
||||
(thumbnail.oldPreviewState.value && boardsData.thumbnail?.url)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center md:flex text-sm mb-2 dark:text-gray-200"
|
||||
>
|
||||
<div class="mx-5 font-medium">
|
||||
{{ boardsData.thumbnail?.name }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 ml-4"
|
||||
@click="onClearThumbnail"
|
||||
>
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'trash']" class="mr-1" />
|
||||
삭제하기
|
||||
</button>
|
||||
</div>
|
||||
<img
|
||||
:src="thumbnail.preview.value || boardsData.thumbnail?.url"
|
||||
class="max-w-full sm:max-w-md h-auto rounded shadow-md mb-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClientOnly class="mb-3 space-y-2">
|
||||
<label for="description" class="font-bold dark:text-gray-200"
|
||||
>내용</label
|
||||
>
|
||||
<ckeditor
|
||||
v-if="editor && ckeditor"
|
||||
:editor="editor"
|
||||
v-model="boardsData.description"
|
||||
:config="editorConfig"
|
||||
></ckeditor>
|
||||
</ClientOnly>
|
||||
<ClientOnly>
|
||||
<FileUploadSlot
|
||||
v-for="(file, index) in uploadsData.files"
|
||||
:length="uploadsData.files.length"
|
||||
:key="index"
|
||||
:index="index"
|
||||
:file="file"
|
||||
:onChange="onPlainFileChange"
|
||||
:onClear="clearFileInput"
|
||||
:onAdd="() => addFileSlot(uploadsData.files)"
|
||||
@remove="removeFileSlot(uploadsData.files, index)"
|
||||
/>
|
||||
</ClientOnly>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline mt-4 text-sm"
|
||||
@click="addFileSlot(uploadsData.files)"
|
||||
>
|
||||
> + 파일 추가
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="block w-full bg-indigo-600 text-white py-3 px-3 rounded transition hover:bg-indigo-700 mt-12 font-semibold dark:bg-indigo-500 dark:hover:bg-indigo-600 disabled:opacity-50 dark:disabled:opacity-60"
|
||||
:disabled="in_submission"
|
||||
>
|
||||
>
|
||||
<div
|
||||
v-if="show_alert"
|
||||
:class="[
|
||||
alert_variant, // Ensure this variable contains dark: variants
|
||||
'text-white text-center font-bold p-4 rounded mb-4',
|
||||
]"
|
||||
>
|
||||
{{ alert_msg }}
|
||||
</div>
|
||||
|
||||
<div v-show="!in_submission">
|
||||
{{ isEdit ? '수정하기' : '게시하기' }}
|
||||
</div>
|
||||
<div v-if="in_submission">
|
||||
<font-awesome-icon :icon="['fas', 'spinner']" spin />
|
||||
</div>
|
||||
</button>
|
||||
<!-- UploadButton -->
|
||||
<div>
|
||||
<UploadBar
|
||||
:is-edit="isEditing"
|
||||
:current-board-status="currentStatus"
|
||||
@submit="handleBoardSubmission"
|
||||
/>
|
||||
</div>
|
||||
</VeeForm>
|
||||
</div>
|
||||
</div>
|
||||
<app-loading-overlay
|
||||
:isLoading="in_submission"
|
||||
:loadingMessage="loadingMessage"
|
||||
></app-loading-overlay>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
watch,
|
||||
reactive,
|
||||
computed,
|
||||
} from 'vue';
|
||||
import AppBoardHeader from '@/components/boards/BoardHeader.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { fetchLatestDocumentNumber } from '@/utils/firebaseUtils';
|
||||
import AppLoadingOverlay from '@/components/LoadingOverlay.vue';
|
||||
import { Switch } from '@headlessui/vue';
|
||||
import { editorConfig } from '@/utils/ckeditorUtils';
|
||||
import {
|
||||
onPlainFileChange,
|
||||
resetFileSlots,
|
||||
getFilesFromUploads,
|
||||
createBoardsData,
|
||||
uploadFiles,
|
||||
onThumbnailChange,
|
||||
handleUpdateBoard,
|
||||
getDeleteFileIndexesFromUploads,
|
||||
clearFileInput,
|
||||
addFileSlot,
|
||||
removeFileSlot,
|
||||
clearThumbnailInput,
|
||||
createEmptyUploadFileData,
|
||||
getBoardDocRef,
|
||||
} from '@/utils/boardUtils';
|
||||
import FileUploadSlot from '@/components/boards/slots/FileUploadSlot.vue';
|
||||
import {
|
||||
Form as VeeForm,
|
||||
Field as VeeField,
|
||||
ErrorMessage as VeeErrorMessage,
|
||||
} from 'vee-validate';
|
||||
import { syncBoardAndUploadsData } from '@/utils/boardUtils';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
//types
|
||||
import { useEditor } from '@/composables/useEditor';
|
||||
import { UploadSettings } from '@/data/config';
|
||||
import type {
|
||||
BoardItem,
|
||||
ThumbnailData,
|
||||
FileItem,
|
||||
UploadFileData,
|
||||
UploadsData,
|
||||
} from '@/types';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
const router = useRouter();
|
||||
const props = defineProps({
|
||||
isEdit: { type: Boolean, default: false },
|
||||
board: { type: Object as PropType<BoardItem>, default: () => ({}) },
|
||||
});
|
||||
const emit = defineEmits(['update-success']);
|
||||
const userStore = useUserStore();
|
||||
const userId = computed(() => userStore.docId);
|
||||
|
||||
//Temp for Testing
|
||||
|
||||
const isEditing = ref(false); // Example: set this based on whether you're editing an existing board
|
||||
const currentStatus = ref<'draft' | 'published' | 'queued' | null>(null); // Example: current status of the board
|
||||
const handleBoardSubmission = async (
|
||||
actionId: string,
|
||||
emittedBoardData: any
|
||||
) => {
|
||||
// `emittedBoardData` is a placeholder from the child; use your actual `boardData`
|
||||
console.log(`Parent received action: ${actionId} for board:`, boardsData);
|
||||
|
||||
// Here, you'll implement the actual logic based on actionId:
|
||||
// 1. Set your boardState.state based on actionId
|
||||
// 2. Prepare the payload (boardData, file uploads if any)
|
||||
// 3. Call your Firebase/backend function (Phase II of our previous discussion)
|
||||
|
||||
// Example:
|
||||
let targetState = actionId; // Assuming actionId maps directly to boardState.state
|
||||
|
||||
// Show loading in parent if needed, or let child handle its own visual state
|
||||
// For this example, child handles its own visual "isProcessing"
|
||||
|
||||
try {
|
||||
// await api.saveBoard(boardData, targetState);
|
||||
console.log(`Board would be saved with state: ${targetState}`);
|
||||
// Update currentStatus if the action was successful and changes it
|
||||
// currentStatus.value = targetState as any; // Cast if necessary
|
||||
} catch (error) {
|
||||
console.error('Failed to save board:', error);
|
||||
// Show error message in parent
|
||||
} finally {
|
||||
// Reset parent loading state if any
|
||||
}
|
||||
};
|
||||
//customize
|
||||
const { $firebase } = useNuxtApp();
|
||||
const noticesCollection = $firebase.noticesCollection;
|
||||
const currentCollection = noticesCollection;
|
||||
const currentBoard = 'notice';
|
||||
const compData = {
|
||||
title: '공지사항 | NOTICE',
|
||||
};
|
||||
//loading Message
|
||||
const loadingMessage = 'Uploading! 잠시만 기다려주세요...';
|
||||
const isUploading = ref(false);
|
||||
//alert
|
||||
const { show_alert, alert_variant, alert_msg, showAlert } = useAlert();
|
||||
|
||||
// Validation
|
||||
const validateInput = (): boolean => {
|
||||
if (!boardsData.value.title.trim()) {
|
||||
showAlert('제목은 필수입니다!', 'bg-red-500', true);
|
||||
in_submission.value = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
//state
|
||||
const in_submission = ref(false);
|
||||
const isEdit = ref(props.isEdit ?? false);
|
||||
const newBoard = ref(props.board);
|
||||
const boardsData: Ref<BoardItem> = ref({
|
||||
docId: '',
|
||||
userId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
boardState: { state: 'processing' },
|
||||
announcement: false,
|
||||
|
||||
created: '',
|
||||
files: [],
|
||||
//depreciated
|
||||
boards_number: 0,
|
||||
thumbnail: { name: '', url: '' } as FileItem,
|
||||
ishidden: false,
|
||||
});
|
||||
|
||||
const thumbnailInput = ref<HTMLInputElement | null>(null);
|
||||
const thumbnail: ThumbnailData = {
|
||||
input: ref(null),
|
||||
preview: ref(''),
|
||||
deleteTrigger: ref(false),
|
||||
oldPreviewState: ref(false),
|
||||
uploadState: ref(false),
|
||||
previewState: ref(false),
|
||||
displayedName: ref(''),
|
||||
};
|
||||
const files = reactive<UploadFileData[]>([]);
|
||||
|
||||
const uploadsData: UploadsData = {
|
||||
thumbnail,
|
||||
files,
|
||||
};
|
||||
//FOR EDIT, Write
|
||||
const { editor, ckeditor, loadEditor } = useEditor();
|
||||
|
||||
const onClearThumbnail = () => {
|
||||
clearThumbnailInput(thumbnail, thumbnailInput.value, () => {
|
||||
boardsData.value.thumbnail = { name: '', url: '' };
|
||||
});
|
||||
};
|
||||
// firebase Upload
|
||||
const handleUpload = async () => {
|
||||
in_submission.value = true;
|
||||
|
||||
if (!validateInput()) {
|
||||
in_submission.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Validate plain files (UploadFileData[])
|
||||
const rejectedFiles = uploadsData.files
|
||||
.map((fileData) => fileData?.input)
|
||||
.filter(
|
||||
(file) => file instanceof File && !UploadSettings.isValidFile(file)
|
||||
);
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
const reason =
|
||||
rejectedFiles[0] instanceof File
|
||||
? UploadSettings.getInvalidFileReasonKey(rejectedFiles[0])
|
||||
: null;
|
||||
const msg = UploadSettings.UploadErrorMessages[reason!];
|
||||
showAlert(msg ?? 'Invalid file', 'bg-red-500');
|
||||
in_submission.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newBoardsNumber = await fetchLatestDocumentNumber(
|
||||
currentCollection,
|
||||
'boards_number'
|
||||
);
|
||||
|
||||
let boardPayload = createBoardsData(boardsData.value, newBoardsNumber);
|
||||
|
||||
// ✅ Collect valid files and thumbnail
|
||||
const { thumbnail, files } = getFilesFromUploads(uploadsData);
|
||||
|
||||
if (isEdit.value) {
|
||||
if (!newBoard.value) {
|
||||
showAlert('기존 게시글 데이터를 찾을 수 없습니다.', 'bg-red-500');
|
||||
in_submission.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const oldFiles = newBoard.value.files ?? [];
|
||||
const oldThumbUrl = newBoard.value.thumbnail?.url ?? '';
|
||||
const deleteThumbnail = uploadsData.thumbnail.deleteTrigger.value;
|
||||
const deleteFileIndexes = getDeleteFileIndexesFromUploads(uploadsData);
|
||||
|
||||
boardPayload = await uploadFiles({
|
||||
newBoardData: boardPayload,
|
||||
newThumbnail: thumbnail instanceof File ? thumbnail : null,
|
||||
newFiles: files,
|
||||
oldThumbnailUrl: oldThumbUrl,
|
||||
oldFiles,
|
||||
deleteThumbnail,
|
||||
deleteFileIndexes,
|
||||
currentBoard,
|
||||
});
|
||||
|
||||
await handleUpdateBoard(boardPayload, currentCollection);
|
||||
showAlert('성공적으로 수정되었습니다', 'bg-green-800');
|
||||
emit('update-success');
|
||||
} else {
|
||||
const { docId: freshDocId } = getBoardDocRef(currentCollection);
|
||||
|
||||
boardPayload = {
|
||||
...boardPayload,
|
||||
docId: freshDocId,
|
||||
userId: userId.value,
|
||||
boardState: { state: 'processing' },
|
||||
};
|
||||
boardPayload = await uploadFiles({
|
||||
newBoardData: boardPayload,
|
||||
newThumbnail: thumbnail,
|
||||
newFiles: files,
|
||||
currentBoard,
|
||||
});
|
||||
|
||||
await handleUpdateBoard(boardPayload, currentCollection);
|
||||
showAlert('성공적으로 생성되었습니다', 'bg-green-800', true);
|
||||
router.push(`/${currentBoard}/${freshDocId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업로드 중 에러 발생:', error);
|
||||
showAlert('업로드 실패: 파일 또는 데이터 에러', 'bg-red-500');
|
||||
} finally {
|
||||
in_submission.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadEditor().catch((error) => {
|
||||
console.error('Error while initializing the editor:', error);
|
||||
});
|
||||
if (uploadsData.files.length === 0) {
|
||||
addFileSlot(uploadsData.files);
|
||||
}
|
||||
console.log('mounted', uploadsData);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.board,
|
||||
(newBoard) => {
|
||||
if (newBoard) {
|
||||
syncBoardAndUploadsData(
|
||||
newBoard,
|
||||
boardsData,
|
||||
uploadsData,
|
||||
createEmptyUploadFileData,
|
||||
resetFileSlots
|
||||
);
|
||||
console.log('newBoard', newBoard);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
<style>
|
||||
.ck-editor__editable {
|
||||
min-height: 500px;
|
||||
}
|
||||
</style>
|
||||
504
bobu/app/components/boards/project/UploadProjectForm.vue
Normal file
504
bobu/app/components/boards/project/UploadProjectForm.vue
Normal file
@@ -0,0 +1,504 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-board-header
|
||||
:h2data="compData.title"
|
||||
v-if="!isEdit"
|
||||
></app-board-header>
|
||||
|
||||
<section
|
||||
class="bg-white dark:bg-gray-900 py-10 w-full min-h-[85vh] mt-20 mb-52"
|
||||
>
|
||||
<div class="mx-auto max-w-5xl w-full py-4 px-6 items-center">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-8">
|
||||
<VeeForm @submit="handleUpload" v-slot="{ isSubmitting }">
|
||||
<!-- Title -->
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="title"
|
||||
class="inline-block mb-2 font-bold dark:text-gray-200"
|
||||
>제목</label
|
||||
>
|
||||
<VeeField
|
||||
name="title"
|
||||
type="text"
|
||||
rules="required|min:3"
|
||||
v-model="boardsData.title"
|
||||
class="block w-full py-1.5 px-3 border rounded text-gray-800 border-gray-300 focus:outline-none focus:border-indigo-500 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 dark:focus:border-indigo-500"
|
||||
placeholder="제목을 입력하세요"
|
||||
/>
|
||||
<VeeErrorMessage
|
||||
name="title"
|
||||
class="text-red-500 dark:text-red-400 text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
<!-- Subtitle -->
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="subtitle"
|
||||
class="inline-block mb-2 font-bold dark:text-gray-200"
|
||||
>부제목</label
|
||||
>
|
||||
<VeeField
|
||||
name="subtitle"
|
||||
type="text"
|
||||
rules="required|min:3"
|
||||
v-model="boardsData.subtitle"
|
||||
class="block w-full py-1.5 px-3 border rounded text-gray-800 border-gray-300 focus:outline-none focus:border-indigo-500 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 dark:focus:border-indigo-500"
|
||||
placeholder="부제목목을 입력하세요"
|
||||
/>
|
||||
<VeeErrorMessage
|
||||
name="subtitle"
|
||||
class="text-red-500 dark:text-red-400 text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
<!-- Author -->
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="author"
|
||||
class="inline-block mb-2 font-bold dark:text-gray-200"
|
||||
>클라이언트</label
|
||||
>
|
||||
<VeeField
|
||||
name="author"
|
||||
type="text"
|
||||
rules="required|min:3"
|
||||
v-model="boardsData.author"
|
||||
class="block w-full py-1.5 px-3 border rounded text-gray-800 border-gray-300 focus:outline-none focus:border-indigo-500 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 dark:focus:border-indigo-500"
|
||||
placeholder="작성자를 입력하세요"
|
||||
/>
|
||||
<VeeErrorMessage
|
||||
name="author"
|
||||
class="text-red-500 dark:text-red-400 text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
<!-- Announcement -->
|
||||
<div class="flex flex-wrap my-4">
|
||||
<div class="mb-3 mr-8">
|
||||
<Switch
|
||||
id="announcement"
|
||||
v-model="boardsData.announcement"
|
||||
class="align-middle"
|
||||
:class="[
|
||||
boardsData.announcement
|
||||
? 'bg-indigo-600 dark:bg-indigo-500'
|
||||
: 'bg-gray-200 dark:bg-gray-600', // Dark mode BG colors
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900', // Dark focus ring offset
|
||||
]"
|
||||
>
|
||||
<span class="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
boardsData.announcement
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out', // Knob bg-white works for both modes
|
||||
]"
|
||||
/>
|
||||
</Switch>
|
||||
<label
|
||||
for="announcement"
|
||||
class="inline-block ml-2 align-middle dark:text-gray-200"
|
||||
>공지글로 설정</label
|
||||
>>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<Switch
|
||||
v-model="boardsData.ishidden"
|
||||
id="ishidden"
|
||||
class="align-middle"
|
||||
:class="[
|
||||
boardsData.ishidden
|
||||
? 'bg-indigo-600 dark:bg-indigo-500'
|
||||
: 'bg-gray-200 dark:bg-gray-600', // Dark mode BG colors
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900', // Dark focus ring offset
|
||||
]"
|
||||
>
|
||||
<span class="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
boardsData.ishidden ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out', // Knob bg-white works for both modes
|
||||
]"
|
||||
/>
|
||||
</Switch>
|
||||
<label
|
||||
for="ishidden"
|
||||
class="inline-block ml-2 align-middle dark:text-gray-200"
|
||||
>비밀글로 설정</label
|
||||
>>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload Thumbnail -->
|
||||
<div class="mb-3 my-4">
|
||||
<label
|
||||
for="thumbnail"
|
||||
class="inline-block font-bold dark:text-gray-200"
|
||||
>이미지 (본문과 함께표시)</label
|
||||
>>
|
||||
|
||||
<input
|
||||
ref="thumbnailInput"
|
||||
v-show="
|
||||
!thumbnail.preview.value &&
|
||||
!(
|
||||
thumbnail.oldPreviewState.value && boardsData.thumbnail?.url
|
||||
)
|
||||
"
|
||||
type="file"
|
||||
accept=".jpg, .jpeg, .png, .webp"
|
||||
@change="(e) => onThumbnailChange(e, thumbnail)"
|
||||
class="block w-full py-1.5 px-3 border border-gray-300 rounded mt-2 text-sm text-gray-900 dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 cursor-pointer focus:outline-none dark:placeholder-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100 dark:file:bg-indigo-700 dark:file:text-indigo-100 dark:hover:file:bg-indigo-600"
|
||||
/>
|
||||
<div
|
||||
class="py-2"
|
||||
v-if="
|
||||
thumbnail.preview.value ||
|
||||
(thumbnail.oldPreviewState.value && boardsData.thumbnail?.url)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center md:flex text-sm mb-2 dark:text-gray-200"
|
||||
>
|
||||
<div class="mx-5 font-medium">
|
||||
{{ boardsData.thumbnail?.name }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 ml-4"
|
||||
@click="onClearThumbnail"
|
||||
>
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'trash']" class="mr-1" />
|
||||
삭제하기
|
||||
</button>
|
||||
</div>
|
||||
<img
|
||||
:src="thumbnail.preview.value || boardsData.thumbnail?.url"
|
||||
class="max-w-full sm:max-w-md h-auto rounded shadow-md mb-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload Video -->
|
||||
<!-- <VideoUploader /> -->
|
||||
<!-- Upload Content -->
|
||||
<ClientOnly class="mb-3 space-y-2">
|
||||
<label for="description" class="font-bold dark:text-gray-200"
|
||||
>내용</label
|
||||
>
|
||||
<ckeditor
|
||||
v-if="editor && ckeditor"
|
||||
:editor="editor"
|
||||
v-model="boardsData.description"
|
||||
:config="editorConfig"
|
||||
></ckeditor>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Upload Files -->
|
||||
<ClientOnly>
|
||||
<FileUploadSlot
|
||||
v-for="(file, index) in uploadsData.files"
|
||||
:length="uploadsData.files.length"
|
||||
:key="index"
|
||||
:index="index"
|
||||
:file="file"
|
||||
:onChange="onPlainFileChange"
|
||||
:onClear="clearFileInput"
|
||||
:onAdd="() => addFileSlot(uploadsData.files)"
|
||||
@remove="removeFileSlot(uploadsData.files, index)"
|
||||
/>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Add File Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline mt-4 text-sm"
|
||||
@click="addFileSlot(uploadsData.files)"
|
||||
>
|
||||
> + 파일 추가
|
||||
</button>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
class="block w-full bg-indigo-600 text-white py-3 px-3 rounded transition hover:bg-indigo-700 mt-12 font-semibold dark:bg-indigo-500 dark:hover:bg-indigo-600 disabled:opacity-50 dark:disabled:opacity-60"
|
||||
:disabled="in_submission"
|
||||
>
|
||||
>
|
||||
<div
|
||||
v-if="show_alert"
|
||||
:class="[
|
||||
alert_variant, // Ensure this variable contains dark: variants
|
||||
'text-white text-center font-bold p-4 rounded mb-4',
|
||||
]"
|
||||
>
|
||||
{{ alert_msg }}
|
||||
</div>
|
||||
|
||||
<div v-show="!in_submission">
|
||||
{{ isEdit ? '수정하기' : '게시하기' }}
|
||||
</div>
|
||||
<div v-if="in_submission">
|
||||
<font-awesome-icon :icon="['fas', 'spinner']" spin />
|
||||
</div>
|
||||
</button>
|
||||
</VeeForm>
|
||||
</div>
|
||||
</div>
|
||||
<app-loading-overlay
|
||||
:isLoading="in_submission"
|
||||
:loadingMessage="loadingMessage"
|
||||
></app-loading-overlay>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, reactive, computed } from 'vue';
|
||||
import AppBoardHeader from '@/components/boards/BoardHeader.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { fetchLatestDocumentNumber } from '@/utils/firebaseUtils';
|
||||
import AppLoadingOverlay from '@/components/LoadingOverlay.vue';
|
||||
import { Switch } from '@headlessui/vue';
|
||||
import { editorConfig } from '@/utils/ckeditorUtils';
|
||||
import {
|
||||
onPlainFileChange,
|
||||
resetFileSlots,
|
||||
getFilesFromUploads,
|
||||
createBoardsData,
|
||||
uploadFiles,
|
||||
onThumbnailChange,
|
||||
handleUpdateBoard,
|
||||
getDeleteFileIndexesFromUploads,
|
||||
clearFileInput,
|
||||
addFileSlot,
|
||||
removeFileSlot,
|
||||
clearThumbnailInput,
|
||||
createEmptyUploadFileData,
|
||||
getBoardDocRef,
|
||||
} from '@/utils/boardUtils';
|
||||
import FileUploadSlot from '@/components/boards/slots/FileUploadSlot.vue';
|
||||
import VideoUploader from '@/components/boards/slots/VideoUploader.vue';
|
||||
import {
|
||||
Form as VeeForm,
|
||||
Field as VeeField,
|
||||
ErrorMessage as VeeErrorMessage,
|
||||
} from 'vee-validate';
|
||||
|
||||
import { syncBoardAndUploadsData } from '@/utils/boardUtils';
|
||||
|
||||
import { useUserStore } from '@/stores/user';
|
||||
//types
|
||||
import { useEditor } from '@/composables/useEditor';
|
||||
import { UploadSettings } from '@/data/config';
|
||||
import type {
|
||||
ProjectBoard,
|
||||
ThumbnailData,
|
||||
FileItem,
|
||||
UploadFileData,
|
||||
UploadsData,
|
||||
} from '@/types';
|
||||
import type { Ref } from 'vue';
|
||||
const router = useRouter();
|
||||
const props = defineProps({
|
||||
isEdit: { type: Boolean, default: false },
|
||||
board: { type: Object as PropType<ProjectBoard>, default: () => ({}) },
|
||||
});
|
||||
const emit = defineEmits(['update-success']);
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userId = computed(() => userStore.docId);
|
||||
//customize
|
||||
const { $firebase } = useNuxtApp();
|
||||
const projectsCollection = $firebase.projectsCollection;
|
||||
const currentCollection = projectsCollection;
|
||||
const currentBoard = 'project';
|
||||
const compData = {
|
||||
title: '프로젝트 | CMS',
|
||||
};
|
||||
//loading Message
|
||||
const loadingMessage = 'Uploading! 잠시만 기다려주세요...';
|
||||
const isUploading = ref(false);
|
||||
//alert
|
||||
const { show_alert, alert_variant, alert_msg, showAlert } = useAlert();
|
||||
|
||||
// Validation
|
||||
const validateInput = (): boolean => {
|
||||
if (!boardsData.value.title.trim()) {
|
||||
showAlert('제목은 필수입니다!', 'bg-red-500', true);
|
||||
in_submission.value = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
//state
|
||||
const in_submission = ref(false);
|
||||
const isEdit = ref(props.isEdit ?? false);
|
||||
const newBoard = ref(props.board);
|
||||
const boardsData: Ref<ProjectBoard> = ref({
|
||||
docId: '',
|
||||
userId: '',
|
||||
title: '',
|
||||
created: '',
|
||||
boardState: { state: 'pending' },
|
||||
|
||||
description: '',
|
||||
announcement: false,
|
||||
subtitle: '',
|
||||
author: '',
|
||||
displayDate: '',
|
||||
|
||||
ishidden: false,
|
||||
boards_number: 0,
|
||||
|
||||
thumbnail: { name: '', url: '' } as FileItem,
|
||||
files: [],
|
||||
});
|
||||
const thumbnailInput = ref<HTMLInputElement | null>(null);
|
||||
const thumbnail: ThumbnailData = {
|
||||
input: ref(null),
|
||||
preview: ref(''),
|
||||
deleteTrigger: ref(false),
|
||||
oldPreviewState: ref(false),
|
||||
uploadState: ref(false),
|
||||
previewState: ref(false),
|
||||
displayedName: ref(''),
|
||||
};
|
||||
const files = reactive<UploadFileData[]>([]);
|
||||
|
||||
const uploadsData: UploadsData = {
|
||||
thumbnail,
|
||||
files,
|
||||
};
|
||||
//FOR EDIT, Write
|
||||
const { editor, ckeditor, loadEditor } = useEditor();
|
||||
|
||||
const onClearThumbnail = () => {
|
||||
clearThumbnailInput(thumbnail, thumbnailInput.value, () => {
|
||||
boardsData.value.thumbnail = { name: '', url: '' };
|
||||
});
|
||||
};
|
||||
// firebase Upload
|
||||
const handleUpload = async () => {
|
||||
in_submission.value = true;
|
||||
|
||||
if (!validateInput()) {
|
||||
in_submission.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Validate plain files (UploadFileData[])
|
||||
const rejectedFiles = uploadsData.files
|
||||
.map((fileData) => fileData?.input)
|
||||
.filter(
|
||||
(file) => file instanceof File && !UploadSettings.isValidFile(file)
|
||||
);
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
const reason =
|
||||
rejectedFiles[0] instanceof File
|
||||
? UploadSettings.getInvalidFileReasonKey(rejectedFiles[0])
|
||||
: null;
|
||||
const msg = UploadSettings.UploadErrorMessages[reason!];
|
||||
showAlert(msg ?? 'Invalid file', 'bg-red-500');
|
||||
in_submission.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newBoardsNumber = await fetchLatestDocumentNumber(
|
||||
currentCollection,
|
||||
'boards_number'
|
||||
);
|
||||
|
||||
let boardPayload = createBoardsData(boardsData.value, newBoardsNumber);
|
||||
|
||||
// ✅ Collect valid files and thumbnail
|
||||
const { thumbnail, files } = getFilesFromUploads(uploadsData);
|
||||
|
||||
if (isEdit.value) {
|
||||
if (!newBoard.value) {
|
||||
showAlert('기존 게시글 데이터를 찾을 수 없습니다.', 'bg-red-500');
|
||||
in_submission.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const oldFiles = newBoard.value.files ?? [];
|
||||
const oldThumbUrl = newBoard.value.thumbnail?.url ?? '';
|
||||
const deleteThumbnail = uploadsData.thumbnail.deleteTrigger.value;
|
||||
const deleteFileIndexes = getDeleteFileIndexesFromUploads(uploadsData);
|
||||
|
||||
boardPayload = await uploadFiles({
|
||||
newBoardData: boardPayload,
|
||||
newThumbnail: thumbnail instanceof File ? thumbnail : null,
|
||||
newFiles: files,
|
||||
oldThumbnailUrl: oldThumbUrl,
|
||||
oldFiles,
|
||||
deleteThumbnail,
|
||||
deleteFileIndexes,
|
||||
currentBoard,
|
||||
});
|
||||
|
||||
await handleUpdateBoard(boardPayload, currentCollection);
|
||||
showAlert('성공적으로 수정되었습니다', 'bg-green-800');
|
||||
emit('update-success');
|
||||
} else {
|
||||
const { docId: freshDocId } = getBoardDocRef(currentCollection);
|
||||
|
||||
boardPayload = {
|
||||
...boardPayload,
|
||||
docId: freshDocId,
|
||||
userId: userId.value,
|
||||
};
|
||||
boardPayload = await uploadFiles({
|
||||
newBoardData: boardPayload,
|
||||
newThumbnail: thumbnail,
|
||||
newFiles: files,
|
||||
currentBoard,
|
||||
});
|
||||
|
||||
await handleUpdateBoard(boardPayload, currentCollection);
|
||||
showAlert('성공적으로 생성되었습니다', 'bg-green-800', true);
|
||||
router.push(`projects/${freshDocId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('업로드 중 에러 발생:', error);
|
||||
showAlert('업로드 실패: 파일 또는 데이터 에러', 'bg-red-500');
|
||||
} finally {
|
||||
in_submission.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadEditor().catch((error) => {
|
||||
console.error('Error while initializing the editor:', error);
|
||||
});
|
||||
if (uploadsData.files.length === 0) {
|
||||
addFileSlot(uploadsData.files);
|
||||
}
|
||||
console.log('mounted', uploadsData);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.board,
|
||||
(newBoard) => {
|
||||
if (newBoard) {
|
||||
syncBoardAndUploadsData(
|
||||
newBoard,
|
||||
boardsData,
|
||||
uploadsData,
|
||||
createEmptyUploadFileData,
|
||||
resetFileSlots
|
||||
);
|
||||
console.log('newBoard', newBoard);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
<style>
|
||||
.ck-editor__editable {
|
||||
min-height: 500px;
|
||||
}
|
||||
</style>
|
||||
58
bobu/app/components/boards/slots/FileUploadSlot.vue
Normal file
58
bobu/app/components/boards/slots/FileUploadSlot.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="space-y-2 my-8">
|
||||
<label :for="`file-${index}`" class="inline-block mb-2 font-bold">
|
||||
파일 업로드 {{ index + 1 }}
|
||||
</label>
|
||||
<button type="button" @click="onAdd">
|
||||
<font-awesome-icon
|
||||
v-if="index === length - 1"
|
||||
:icon="['fas', 'plus']"
|
||||
class="text-blue-600 text-sm mt-1 ml-3"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="index > 0"
|
||||
type="button"
|
||||
class="text-red-600 text-sm mt-1 ml-3 underline"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'xmark']" style="color: #ff0000" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="file.uploadState"
|
||||
class="flex cursor-pointer"
|
||||
@click="() => onClear(file, `file-${index}`)"
|
||||
>
|
||||
<div class="mx-5">{{ file.displayedName }}</div>
|
||||
<div class="flex items-center">
|
||||
<font-awesome-icon class="px-2" :icon="['fas', 'trash']" />
|
||||
<div>삭제하기</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
:id="`file-${index}`"
|
||||
type="file"
|
||||
class="block w-full py-1.5 px-3"
|
||||
@change="(e) => onChange(e, file)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
index: number
|
||||
file: any
|
||||
length: number
|
||||
onChange: (e: Event, file: any) => void
|
||||
onClear: (file: any, id: string) => void
|
||||
onAdd: () => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['remove'])
|
||||
|
||||
const handleRemove = () => {
|
||||
emit('remove')
|
||||
}
|
||||
</script>
|
||||
137
bobu/app/components/boards/slots/VideoUploader.vue
Normal file
137
bobu/app/components/boards/slots/VideoUploader.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="bg-white rounded border border-gray-200 relative flex flex-col">
|
||||
<div class="px-6 pt-6 pb-5 font-bold border-b border-gray-200">
|
||||
<span class="card-title">Upload</span>
|
||||
<i class="fas fa-upload float-right text-green-400 text-2xl"></i>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- Upload Dropbox -->
|
||||
<div
|
||||
class="w-full px-10 py-20 rounded text-center cursor-pointer border border-dashed border-gray-400 text-gray-400 transition duration-500 hover:text-white hover:bg-green-400 hover:border-green-400 hover:border-solid"
|
||||
:class="{ 'bg-green-400 border-green-400 border-solid': is_dragover }"
|
||||
accept="video/*"
|
||||
@drag.prevent.stop=""
|
||||
@dragstart.prevent.stop=""
|
||||
@dragend.prevent.stop="is_dragover = false"
|
||||
@dragover.prevent.stop="is_dragover = true"
|
||||
@dragenter.prevent.stop="is_dragover = true"
|
||||
@dragleave.prevent.stop="is_dragover = false"
|
||||
@drop.prevent.stop="upload($event)"
|
||||
>
|
||||
<h5>Drop your files here</h5>
|
||||
</div>
|
||||
<input type="file" multiple accept="video/*" @change="upload($event)" />
|
||||
<hr class="my-6" />
|
||||
<!-- Progess Bars -->
|
||||
<div class="mb-4" v-for="upload in uploads" :key="upload.name">
|
||||
<!-- File Name -->
|
||||
<div class="font-bold text-sm" :class="upload.text_class">
|
||||
<i :class="upload.icon"></i>{{ upload.name }}
|
||||
</div>
|
||||
<div class="flex h-4 overflow-hidden bg-gray-200 rounded">
|
||||
<!-- Inner Progress Bar -->
|
||||
<div
|
||||
class="transition-all progress-bar bg-blue-400"
|
||||
:class="upload.variant"
|
||||
:style="{ width: upload.current_progress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { getFunctions, httpsCallable } from 'firebase/functions';
|
||||
|
||||
// Firebase Functions
|
||||
|
||||
// Reactive State
|
||||
const is_dragover = ref(false);
|
||||
|
||||
// Define type for each upload item
|
||||
interface UploadItem {
|
||||
current_progress: number;
|
||||
name: string;
|
||||
variant: string;
|
||||
icon: string;
|
||||
text_class: string;
|
||||
}
|
||||
|
||||
// Uploads list with typed items
|
||||
const uploads = ref<UploadItem[]>([]);
|
||||
|
||||
// Upload handler function
|
||||
const upload = async ($event: DragEvent | Event) => {
|
||||
is_dragover.value = false;
|
||||
|
||||
const { $firebase } = useNuxtApp();
|
||||
const firebaseApp = $firebase.app();
|
||||
const functions = getFunctions(firebaseApp);
|
||||
|
||||
const files =
|
||||
'dataTransfer' in $event
|
||||
? [...($event as DragEvent).dataTransfer!.files]
|
||||
: [...($event.target as HTMLInputElement).files!];
|
||||
|
||||
const uploadVideoToVimeo = async (file: File, uploadIndex: number) => {
|
||||
try {
|
||||
const fileSize = file.size;
|
||||
const getVimeoUploadDetails = httpsCallable(
|
||||
functions,
|
||||
'getVimeoUploadDetails'
|
||||
);
|
||||
const result = await getVimeoUploadDetails({ fileSize });
|
||||
|
||||
if (!result.data || typeof result.data !== 'object') {
|
||||
throw new Error('No valid data received from the function');
|
||||
}
|
||||
|
||||
const { uploadLink, completeUri } = result.data as {
|
||||
uploadLink: string;
|
||||
completeUri: string;
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file_data', file);
|
||||
|
||||
const uploadResponse = await fetch(uploadLink, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Failed to upload video: ${uploadResponse.statusText}`);
|
||||
}
|
||||
|
||||
if (uploads.value[uploadIndex]) {
|
||||
uploads.value[uploadIndex].current_progress = 100;
|
||||
uploads.value[uploadIndex].variant = 'bg-green-400';
|
||||
uploads.value[uploadIndex].icon = 'fas fa-check';
|
||||
uploads.value[uploadIndex].text_class = 'text-green-400';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading:', error);
|
||||
if (uploads.value[uploadIndex]) {
|
||||
uploads.value[uploadIndex].variant = 'bg-red-400';
|
||||
uploads.value[uploadIndex].icon = 'fas fa-times';
|
||||
uploads.value[uploadIndex].text_class = 'text-red-400';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
files.forEach((file) => {
|
||||
const uploadIndex =
|
||||
uploads.value.push({
|
||||
current_progress: 0,
|
||||
name: file.name,
|
||||
variant: 'bg-blue-400',
|
||||
icon: 'fas fa-spinner fa-spin',
|
||||
text_class: '',
|
||||
}) - 1;
|
||||
|
||||
uploadVideoToVimeo(file, uploadIndex);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
101
bobu/app/components/color-mode-selector.vue
Normal file
101
bobu/app/components/color-mode-selector.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center"
|
||||
:class="{ 'space-x-2': showNextModelLabel && !isMobile }"
|
||||
>
|
||||
<div
|
||||
v-if="showNextModelLabel && !isMobile"
|
||||
class="text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Change to {{ nextMode }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="toggleMode"
|
||||
@focus="showNextModelLabel = true"
|
||||
@blur="showNextModelLabel = false"
|
||||
@mouseenter="!isMobile && (showNextModelLabel = true)"
|
||||
@mouseleave="!isMobile && (showNextModelLabel = false)"
|
||||
type="button"
|
||||
class="relative w-10 h-6 rounded-full bg-cover bg-center ring-1 ring-gray-300 dark:ring-gray-600 hover:ring-2 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 transition-all duration-300 outline-none"
|
||||
:style="{ backgroundImage: `url('${nextBg}')` }"
|
||||
aria-label="Toggle color mode"
|
||||
>
|
||||
<div
|
||||
class="absolute top-[2px] h-5 w-5 bg-no-repeat bg-contain transition-all duration-300 pointer-events-none"
|
||||
:class="{
|
||||
'left-[2px]': colorMode.preference === 'light',
|
||||
'left-[18px]': colorMode.preference === 'dark', // Adjusted for w-10 button: 40px (button) - 20px (ball) - 2px (offset) = 18px
|
||||
}"
|
||||
:style="{ backgroundImage: `url('${currentBall}')` }"
|
||||
></div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { COLORSELECTOR_BG } from '@/data/assets'; // Make sure this path is correct
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useColorMode } from '#imports'; // Assuming this is from @nuxtjs/color-mode
|
||||
|
||||
const colorMode = useColorMode();
|
||||
const showNextModelLabel = ref(false);
|
||||
const isMobile = ref(false);
|
||||
|
||||
const checkMobile = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
isMobile.value = window.innerWidth < 768; // Example breakpoint for mobile, adjust as needed
|
||||
}
|
||||
};
|
||||
|
||||
// Lock system mode to resolved value on first mount
|
||||
onMounted(() => {
|
||||
if (colorMode.preference === 'system') {
|
||||
colorMode.preference = colorMode.value; // colorMode.value should be the resolved mode (light/dark)
|
||||
}
|
||||
checkMobile();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', checkMobile);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggling behavior: only light <--> dark
|
||||
const nextMode = computed(() => {
|
||||
return colorMode.preference === 'dark' ? 'light' : 'dark';
|
||||
});
|
||||
|
||||
// Background image based on the NEXT mode
|
||||
const nextBg = computed(() =>
|
||||
nextMode.value === 'light' ? COLORSELECTOR_BG.Light : COLORSELECTOR_BG.Dark
|
||||
);
|
||||
|
||||
// Ball image based on the CURRENT mode
|
||||
const currentBall = computed(() =>
|
||||
colorMode.preference === 'light'
|
||||
? COLORSELECTOR_BG.Sun
|
||||
: COLORSELECTOR_BG.Moon
|
||||
);
|
||||
|
||||
// Toggle action
|
||||
const toggleMode = () => {
|
||||
colorMode.preference = nextMode.value;
|
||||
// Optionally hide the label after a click on mobile if it was shown by focus
|
||||
if (isMobile.value) {
|
||||
showNextModelLabel.value = false;
|
||||
// Blur the button to remove focus styling if desired after a tap
|
||||
// if (document.activeElement instanceof HTMLElement) {
|
||||
// document.activeElement.blur();
|
||||
// }
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any additional scoped styles here if needed */
|
||||
</style>
|
||||
16
bobu/app/components/contact/ContactUs.vue
Normal file
16
bobu/app/components/contact/ContactUs.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Contact Section -->
|
||||
<SendMessage />
|
||||
|
||||
<ClientOnly>
|
||||
<NaverMap />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import SendMessage from '@/components/contact/SendMessage.vue';
|
||||
import NaverMap from '@/components/NaverMap.vue';
|
||||
|
||||
const renderMap = ref(false);
|
||||
</script>
|
||||
36
bobu/app/components/contact/SendMessage.vue
Normal file
36
bobu/app/components/contact/SendMessage.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<section
|
||||
id="contact"
|
||||
class="contact py-20 px-4 bg-white dark:bg-gray-900 text-gray-800 dark:text-white"
|
||||
>
|
||||
<h2 class="text-3xl font-semibold text-center mb-6">
|
||||
Let's Create Together
|
||||
</h2>
|
||||
<p class="text-center mb-10 text-gray-600 dark:text-gray-400">
|
||||
Ready to start your next project? We’d love to hear from you.
|
||||
</p>
|
||||
<form class="max-w-2xl mx-auto space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your Name"
|
||||
class="w-full p-3 rounded-md bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your Email"
|
||||
class="w-full p-3 rounded-md bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400"
|
||||
/>
|
||||
<textarea
|
||||
rows="4"
|
||||
placeholder="Your Message"
|
||||
class="w-full p-3 rounded-md bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400"
|
||||
></textarea>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-gray-900 text-white py-3 rounded-md hover:bg-gray-800 transition dark:bg-white dark:text-black dark:hover:bg-gray-100"
|
||||
>
|
||||
Send Message
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
277
bobu/app/components/header/CmsSidebar.vue
Normal file
277
bobu/app/components/header/CmsSidebar.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Mobile sidebar -->
|
||||
<TransitionRoot as="template" :show="open">
|
||||
<Dialog class="relative z-50 lg:hidden" @close="close()">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="transition-opacity ease-linear duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="transition-opacity ease-linear duration-300"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-gray-900/80" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 flex">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="transition ease-in-out duration-300 transform"
|
||||
enter-from="-translate-x-full"
|
||||
enter-to="translate-x-0"
|
||||
leave="transition ease-in-out duration-300 transform"
|
||||
leave-from="translate-x-0"
|
||||
leave-to="-translate-x-full"
|
||||
>
|
||||
<DialogPanel class="relative mr-16 flex w-full max-w-xs flex-1">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="ease-in-out duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="ease-in-out duration-300"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-full flex w-16 justify-center pt-5"
|
||||
>
|
||||
<button type="button" class="-m-2.5 p-2.5" @click="close()">
|
||||
<span class="sr-only">Close sidebar</span>
|
||||
<XMarkIcon class="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
|
||||
<div
|
||||
class="flex grow flex-col gap-y-5 overflow-y-auto bg-indigo-600 dark:bg-gray-800 px-6 pb-4"
|
||||
>
|
||||
<!-- LOGO -->
|
||||
<div class="flex h-16 shrink-0 items-center">
|
||||
<img
|
||||
class="h-8 w-auto"
|
||||
src="https://tailwindcss.com/img/logos/mark.svg?color=white"
|
||||
alt="Your Company"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- NAVIGATION -->
|
||||
<nav class="flex flex-1 flex-col">
|
||||
<ul role="list" class="flex flex-1 flex-col gap-y-7">
|
||||
<!-- top-level links -->
|
||||
<li>
|
||||
<ul role="list" class="-mx-2 space-y-1">
|
||||
<li v-for="item in navigation" :key="item.name">
|
||||
<NuxtLink
|
||||
:to="item.href"
|
||||
:class="[
|
||||
item.current
|
||||
? 'bg-indigo-700 text-white dark:bg-indigo-900'
|
||||
: 'text-indigo-200 hover:bg-indigo-700 hover:text-white dark:text-gray-300 dark:hover:bg-gray-900 dark:hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold',
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
:class="[
|
||||
item.current
|
||||
? 'text-white dark:text-indigo-100'
|
||||
: 'text-indigo-200 group-hover:text-white dark:text-gray-300 dark:group-hover:text-indigo-100',
|
||||
'h-6 w-6 shrink-0',
|
||||
]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ item.name }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- teams -->
|
||||
<li>
|
||||
<div
|
||||
class="text-xs font-semibold leading-6 text-indigo-200 dark:text-gray-400"
|
||||
>
|
||||
Your teams
|
||||
</div>
|
||||
<ul role="list" class="-mx-2 mt-2 space-y-1">
|
||||
<li v-for="team in teams" :key="team.name">
|
||||
<NuxtLink
|
||||
:to="team.href"
|
||||
:class="[
|
||||
team.current
|
||||
? 'bg-indigo-700 text-white dark:bg-indigo-900'
|
||||
: 'text-indigo-200 hover:bg-indigo-700 hover:text-white dark:text-gray-300 dark:hover:bg-gray-900 dark:hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border border-indigo-400 bg-indigo-500 text-[0.625rem] font-medium text-white dark:border-gray-600 dark:bg-gray-700"
|
||||
>
|
||||
{{ team.initial }}
|
||||
</span>
|
||||
<span class="truncate">{{ team.name }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- settings -->
|
||||
<li class="mt-auto">
|
||||
<NuxtLink
|
||||
to="./"
|
||||
class="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-indigo-200 hover:bg-indigo-700 hover:text-white dark:text-gray-300 dark:hover:bg-gray-900 dark:hover:text-white"
|
||||
>
|
||||
<Cog6ToothIcon
|
||||
class="h-6 w-6 shrink-0 text-indigo-200 group-hover:text-white dark:text-gray-300 dark:group-hover:text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Settings
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
|
||||
<!-- Desktop sidebar -->
|
||||
<div
|
||||
class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex grow flex-col gap-y-5 overflow-y-auto bg-indigo-600 dark:bg-gray-800 px-6 pb-4 ring-1 ring-white/10 dark:ring-black/10"
|
||||
>
|
||||
<!-- LOGO -->
|
||||
<div class="flex h-16 shrink-0 items-center">
|
||||
<img
|
||||
class="h-8 w-auto"
|
||||
src="https://tailwindcss.com/img/logos/mark.svg?color=white"
|
||||
alt="Your Company"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- NAVIGATION (desktop) -->
|
||||
<nav class="flex flex-1 flex-col">
|
||||
<ul role="list" class="flex flex-1 flex-col gap-y-7">
|
||||
<!-- duplicate same blocks as mobile -->
|
||||
<li v-for="item in navigation" :key="item.name">
|
||||
<NuxtLink
|
||||
:to="item.href"
|
||||
:class="[
|
||||
item.current
|
||||
? 'bg-indigo-700 text-white dark:bg-indigo-900'
|
||||
: 'text-indigo-200 hover:bg-indigo-700 hover:text-white dark:text-gray-300 dark:hover:bg-gray-900 dark:hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold',
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
:class="[
|
||||
item.current
|
||||
? 'text-white dark:text-indigo-100'
|
||||
: 'text-indigo-200 group-hover:text-white dark:text-gray-300 dark:group-hover:text-indigo-100',
|
||||
'h-6 w-6 shrink-0',
|
||||
]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ item.name }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
|
||||
<!-- teams -->
|
||||
<li>
|
||||
<div
|
||||
class="text-xs font-semibold leading-6 text-indigo-200 dark:text-gray-400"
|
||||
>
|
||||
Your teams
|
||||
</div>
|
||||
<ul role="list" class="-mx-2 mt-2 space-y-1">
|
||||
<li v-for="team in teams" :key="team.name">
|
||||
<NuxtLink
|
||||
:to="team.href"
|
||||
:class="[
|
||||
team.current
|
||||
? 'bg-indigo-700 text-white dark:bg-indigo-900'
|
||||
: 'text-indigo-200 hover:bg-indigo-700 hover:text-white dark:text-gray-300 dark:hover:bg-gray-900 dark:hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border border-indigo-400 bg-indigo-500 text-[0.625rem] font-medium text-white dark:border-gray-600 dark:bg-gray-700"
|
||||
>
|
||||
{{ team.initial }}
|
||||
</span>
|
||||
<span class="truncate">{{ team.name }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- settings -->
|
||||
<li class="mt-auto">
|
||||
<NuxtLink
|
||||
to="./"
|
||||
class="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-indigo-200 hover:bg-indigo-700 hover:text-white dark:text-gray-300 dark:hover:bg-gray-900 dark:hover:text-white"
|
||||
>
|
||||
<Cog6ToothIcon
|
||||
class="h-6 w-6 shrink-0 text-indigo-200 group-hover:text-white dark:text-gray-300 dark:group-hover:text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Settings
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'; // ← make sure you have this
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from '@headlessui/vue';
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
DocumentDuplicateIcon,
|
||||
FolderIcon,
|
||||
HomeIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
// we’ll just call `emit('close')` whenever you want to hide
|
||||
const close = () => emit('close');
|
||||
|
||||
// your nav data
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/cms/dashboard', icon: HomeIcon, current: true },
|
||||
{ name: 'Upload New', href: '/cms/upload', icon: FolderIcon, current: false },
|
||||
{
|
||||
name: 'Posts',
|
||||
href: '/cms/posts',
|
||||
icon: DocumentDuplicateIcon,
|
||||
current: false,
|
||||
},
|
||||
];
|
||||
const teams = [
|
||||
{ name: 'Content Writers', href: '#', initial: 'CW', current: false },
|
||||
{ name: 'Editors', href: '#', initial: 'E', current: false },
|
||||
];
|
||||
|
||||
// expose `open` to the template
|
||||
const open = computed(() => props.open);
|
||||
</script>
|
||||
53
bobu/app/components/header/CmsTopbar.vue
Normal file
53
bobu/app/components/header/CmsTopbar.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<!-- Topbar -->
|
||||
<div
|
||||
class="sticky top-0 z-40 flex h-16 items-center gap-x-4 border-b bg-white px-4 shadow-sm dark:bg-gray-800 dark:border-gray-700 sm:gap-x-6 sm:px-6 lg:px-8"
|
||||
>
|
||||
<!-- Mobile hamburger -->
|
||||
<button
|
||||
type="button"
|
||||
class="-m-2.5 p-2.5 text-gray-700 lg:hidden dark:text-gray-300"
|
||||
@click="$emit('toggle-sidebar')"
|
||||
>
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<!-- Divider for mobile -->
|
||||
<div
|
||||
class="h-6 w-px bg-gray-900/10 lg:hidden dark:bg-gray-100/10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
||||
<!-- Search -->
|
||||
<AppCmsSearchBar />
|
||||
|
||||
<!-- Notifications & Upload -->
|
||||
<div class="flex items-center gap-x-4 lg:gap-x-6">
|
||||
<button
|
||||
type="button"
|
||||
class="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400"
|
||||
>
|
||||
<span class="sr-only">View notifications</span>
|
||||
<BellIcon class="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-900/10 dark:lg:bg-gray-100/10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<AppCmsUploadSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Bars3Icon, BellIcon } from '@heroicons/vue/24/outline';
|
||||
import AppCmsSearchBar from './slots/CmsSearchBar.vue';
|
||||
import AppCmsUploadSelector from './slots/CmsUploadSelector.vue';
|
||||
|
||||
// you can still react to `props.open` here if needed for styling
|
||||
</script>
|
||||
74
bobu/app/components/header/HeaderAction.vue
Normal file
74
bobu/app/components/header/HeaderAction.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<div class="flex items-center gap-x-4" :class="containerClass">
|
||||
<!-- Cart Icon (always visible) -->
|
||||
|
||||
<NuxtLink
|
||||
to="/shop"
|
||||
:class="linkClass + ' flex items-center'"
|
||||
aria-label="장바구니"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'shopping-cart']" class="h-5 w-5" />
|
||||
</NuxtLink>
|
||||
|
||||
<!-- If not logged in: show 로그인 / 회원가입 -->
|
||||
<template v-if="!userStore.userLoggedIn">
|
||||
<NuxtLink
|
||||
to="/login"
|
||||
:class="linkClass + ' flex items-center h-5 w-5'"
|
||||
aria-label="로그인"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'user']" class="h-5 w-5" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<!-- If logged in: show 마이페이지 / 로그아웃 -->
|
||||
<template v-else>
|
||||
<NuxtLink
|
||||
to="/mypage"
|
||||
:class="linkClass + ' text-sm font-semibold leading-6'"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'user']" class="h-5 w-5" />
|
||||
마이페이지
|
||||
</NuxtLink>
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="handleLogout"
|
||||
:class="linkClass + ' text-sm font-semibold leading-6'"
|
||||
>
|
||||
로그아웃
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import useUserStore from '@/stores/user';
|
||||
import { navigateTo } from '#app';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
|
||||
// Accept optional classes for container and links
|
||||
const props = defineProps({
|
||||
containerClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
linkClass: {
|
||||
type: String,
|
||||
default:
|
||||
'text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300',
|
||||
},
|
||||
});
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await userStore.signOut();
|
||||
navigateTo('/login');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* No additional scoped CSS needed—relies entirely on Tailwind utility classes */
|
||||
</style>
|
||||
17
bobu/app/components/header/slots/CmsSearchBar.vue
Normal file
17
bobu/app/components/header/slots/CmsSearchBar.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<!-- Search Form -->
|
||||
<form class="relative flex flex-1" action="#" method="GET">
|
||||
<label for="search-field" class="sr-only">Search</label>
|
||||
<MagnifyingGlassIcon
|
||||
class="pointer-events-none absolute inset-y-0 left-0 h-full w-5 text-gray-400 dark:text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
id="search-field"
|
||||
class="block h-full w-full border-0 py-0 pl-8 pr-0 text-gray-900 dark:text-gray-100 dark:bg-gray-800 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:ring-0 sm:text-sm"
|
||||
placeholder="Search..."
|
||||
type="search"
|
||||
name="search"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
86
bobu/app/components/header/slots/CmsUploadSelector.vue
Normal file
86
bobu/app/components/header/slots/CmsUploadSelector.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="inline-flex rounded-md shadow-sm">
|
||||
<!-- Primary action button -->
|
||||
<button
|
||||
type="button"
|
||||
class="py-2 px-3 sm:px-4 text-sm font-semibold text-white transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 rounded-l-md"
|
||||
:disabled="cms.isProcessing"
|
||||
@click="cms.performAction(cms.mainAction.id)"
|
||||
>
|
||||
<span
|
||||
v-if="cms.isProcessing && cms.currentActionId === cms.mainAction.id"
|
||||
class="flex items-center"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'spinner']" spin class="mr-2" />
|
||||
Processing...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ cms.mainAction.label }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown toggle + menu -->
|
||||
<Menu as="div" class="relative">
|
||||
<MenuButton
|
||||
class="inline-flex items-center justify-center px-2 sm:px-2.5 py-2 text-white transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 rounded-r-md border-l border-indigo-700 dark:border-indigo-600"
|
||||
>
|
||||
<span class="sr-only">Open options</span>
|
||||
<ChevronDownIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</MenuButton>
|
||||
|
||||
<Transition
|
||||
enter="transition ease-out duration-100"
|
||||
enter-from="transform opacity-0 scale-95"
|
||||
enter-to="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leave-from="transform opacity-100 scale-100"
|
||||
leave-to="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute right-0 z-10 mt-2.5 w-56 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-800 dark:ring-gray-700"
|
||||
>
|
||||
<MenuItem
|
||||
v-for="action in cms.dropdownActions"
|
||||
:key="action.id"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<button
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
:class="{
|
||||
'opacity-50 cursor-wait':
|
||||
cms.isProcessing && cms.currentActionId === action.id,
|
||||
'font-semibold bg-gray-50 dark:bg-gray-700':
|
||||
action.id === cms.currentActionId,
|
||||
}"
|
||||
:disabled="cms.isProcessing"
|
||||
@click="cms.performAction(action.id)"
|
||||
>
|
||||
<span
|
||||
v-if="cms.isProcessing && cms.currentActionId === action.id"
|
||||
class="flex items-center"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'spinner']"
|
||||
spin
|
||||
class="mr-2"
|
||||
/>
|
||||
Processing...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ action.label }}
|
||||
</span>
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue';
|
||||
import { ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||
import { useCmsStore } from '@/stores/useCmsStore';
|
||||
|
||||
const cms = useCmsStore();
|
||||
</script>
|
||||
26
bobu/app/components/menu.vue
Normal file
26
bobu/app/components/menu.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<nav>
|
||||
<ul class="flex flex-col md:flex-row md:space-x-4">
|
||||
<li>
|
||||
<NuxtLink to="/" class="link">Main</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/about" class="link">About</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/projects" class="link">Projects</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/blog" class="link">Blog</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style scoped>
|
||||
.link {
|
||||
@apply p-1 hover:bg-gray-200 dark:hover:bg-gray-800 text-2xl md:text-base;
|
||||
}
|
||||
</style>
|
||||
22
bobu/app/composables/promoteUser.ts
Normal file
22
bobu/app/composables/promoteUser.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// utils/api/promoteUser.ts
|
||||
import { getApp } from 'firebase/app';
|
||||
import { getAuth } from 'firebase/auth';
|
||||
import { getFunctions, httpsCallable } from 'firebase/functions';
|
||||
|
||||
/**
|
||||
* Promote (or demote) a user by UID.
|
||||
* Caller must already have role ≥ 7 or the callable will return 403/permission‑denied.
|
||||
*/
|
||||
export async function promoteUser(uid: string, role = 7): Promise<void> {
|
||||
// ⛔ Only run on the client
|
||||
if (import.meta.server) {
|
||||
throw new Error('promoteUser must be called from the client');
|
||||
}
|
||||
|
||||
// Make sure our own token is fresh so the callable sees the admin claim
|
||||
await getAuth().currentUser?.getIdToken(true);
|
||||
|
||||
// Call the Cloud Function
|
||||
const fn = httpsCallable(getFunctions(getApp()), 'promoteUser');
|
||||
await fn({ uid, role }); // { ok: true } on success
|
||||
}
|
||||
30
bobu/app/composables/useAlert.ts
Normal file
30
bobu/app/composables/useAlert.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useAlert() {
|
||||
const show_alert = ref(false);
|
||||
const alert_variant = ref('');
|
||||
const alert_msg = ref('');
|
||||
|
||||
const showAlert = (
|
||||
message: string,
|
||||
variant: string,
|
||||
autoHide: boolean = false
|
||||
) => {
|
||||
alert_msg.value = message;
|
||||
alert_variant.value = variant;
|
||||
show_alert.value = true;
|
||||
|
||||
if (autoHide) {
|
||||
setTimeout(() => {
|
||||
show_alert.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
show_alert,
|
||||
alert_variant,
|
||||
alert_msg,
|
||||
showAlert,
|
||||
};
|
||||
}
|
||||
191
bobu/app/composables/useBoardList.ts
Normal file
191
bobu/app/composables/useBoardList.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { ref, shallowRef, watch, computed } from 'vue';
|
||||
import { useNuxtApp } from '#app';
|
||||
import { collection } from 'firebase/firestore';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { deleteSelected } from '@/utils/boardUtils';
|
||||
import { fetchCountsFromFunction } from '~/utils/api/countBoards';
|
||||
import { fetchBoardsFromFunction } from '~/utils/api/fetchBoardsFromFunction';
|
||||
import type {
|
||||
BoardItem,
|
||||
OrderByDirection,
|
||||
SortOption,
|
||||
CursorResponse,
|
||||
UseBoardListOptions,
|
||||
} from '@/types';
|
||||
|
||||
export function useBoardList<T extends BoardItem>(
|
||||
currentCollection: string,
|
||||
options: UseBoardListOptions = {}
|
||||
) {
|
||||
const {
|
||||
title = 'Board',
|
||||
itemsPerPage = 20,
|
||||
defaultSort = 'desc',
|
||||
access = 'public',
|
||||
loadingMessage = '잠시만 기다려주세요...',
|
||||
} = options;
|
||||
/* UI + meta */
|
||||
const compdata = ref({ title });
|
||||
const isLoading = ref(false);
|
||||
const sortingOrder = ref<OrderByDirection>(defaultSort);
|
||||
|
||||
/* count for numbered buttons */
|
||||
const totalItems = ref(0);
|
||||
const totalPages = computed(() => Math.ceil(totalItems.value / itemsPerPage));
|
||||
const pageNumbers = computed(() =>
|
||||
Array.from({ length: totalPages.value }, (_, i) => i + 1)
|
||||
);
|
||||
|
||||
/* cursor bookkeeping */
|
||||
const currentPage = ref(1); // 1‑based for templates
|
||||
const pageCursors = shallowRef<string[]>([]); // token that *starts* each page (page 1 = undefined)
|
||||
const nextPageToken = ref<string | null>(null);
|
||||
const hasMore = computed(() => !!nextPageToken.value);
|
||||
|
||||
/* data */
|
||||
const currentItems = ref<T[]>([]);
|
||||
|
||||
/* selection helpers */
|
||||
const selectedItems = shallowRef<T[]>([]);
|
||||
const showSelectBoxes = ref(false);
|
||||
|
||||
const sortOptions: SortOption[] = [
|
||||
{ sortId: 1, name: '내림차순', rule: 'desc' },
|
||||
{ sortId: 2, name: '오름차순', rule: 'asc' },
|
||||
];
|
||||
const selectedSort = ref<SortOption>(sortOptions[0]!);
|
||||
/* user role (for conditional buttons etc.) */
|
||||
const userRole = computed(() => useUserStore().userRole);
|
||||
|
||||
const fetchPageData = async (
|
||||
token: string | undefined
|
||||
): Promise<CursorResponse<T>> =>
|
||||
fetchBoardsFromFunction<T>(currentCollection, {
|
||||
sortOrder: sortingOrder.value,
|
||||
itemsPerPage,
|
||||
access,
|
||||
pageToken: token,
|
||||
});
|
||||
|
||||
// Fetch a specific *numbered* page
|
||||
// (builds cursors as it goes so later jumps are free)
|
||||
const fetchPage = async (page: number) => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
/* 1. Count only once (needed for numbered buttons) */
|
||||
if (page === 1) {
|
||||
totalItems.value = await fetchCountsFromFunction(
|
||||
currentCollection,
|
||||
access
|
||||
);
|
||||
}
|
||||
|
||||
/* 2. Ensure we have cursors up to (page‑1) -------------------- */
|
||||
while (pageCursors.value.length < page - 1 && hasMore.value) {
|
||||
await loadMore(); // walk forward & cache
|
||||
}
|
||||
|
||||
/* 3. Now fetch the requested page ---------------------------- */
|
||||
const tokenForPage = pageCursors.value[page - 1] || undefined;
|
||||
const res = await fetchPageData(tokenForPage);
|
||||
|
||||
currentItems.value = res.items;
|
||||
nextPageToken.value = res.nextPageToken;
|
||||
currentPage.value = page;
|
||||
|
||||
/* 4. Record cursor for NEXT page (index = page) -------------- */
|
||||
if (pageCursors.value.length < page)
|
||||
pageCursors.value[page] = res.nextPageToken ?? '';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/* Infinite scroll / "Load more" */
|
||||
const loadMore = async () => {
|
||||
if (!hasMore.value || isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const res = await fetchPageData(nextPageToken.value || undefined);
|
||||
|
||||
currentItems.value = [...currentItems.value, ...res.items] as T[];
|
||||
|
||||
/* record start token of the *new* page */
|
||||
pageCursors.value.push(nextPageToken.value ?? '');
|
||||
nextPageToken.value = res.nextPageToken;
|
||||
currentPage.value += 1;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
/* ----------------------------------------------------------------
|
||||
Navigation helpers
|
||||
---------------------------------------------------------------- */
|
||||
const onGoToPage = (n: number) => fetchPage(n);
|
||||
const onPrevPage = () =>
|
||||
currentPage.value > 1 && fetchPage(currentPage.value - 1);
|
||||
const onNextPage = () =>
|
||||
currentPage.value < totalPages.value && fetchPage(currentPage.value + 1);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Selection / deletion
|
||||
---------------------------------------------------------------- */
|
||||
const onToggleSelect = (item: T) => {
|
||||
const idx = selectedItems.value.findIndex((i) => i.docId === item.docId);
|
||||
selectedItems.value =
|
||||
idx === -1
|
||||
? [...selectedItems.value, item]
|
||||
: selectedItems.value.filter((i) => i.docId !== item.docId);
|
||||
};
|
||||
|
||||
const onDeleteSelected = async () => {
|
||||
if (!import.meta.client) return;
|
||||
const { $firebase } = useNuxtApp();
|
||||
const collectionRef = collection($firebase.db, currentCollection);
|
||||
await deleteSelected(
|
||||
selectedItems,
|
||||
showSelectBoxes,
|
||||
collectionRef,
|
||||
$firebase.storage
|
||||
);
|
||||
await fetchPage(currentPage.value);
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
React to sort‑order change
|
||||
---------------------------------------------------------------- */
|
||||
watch(
|
||||
selectedSort,
|
||||
() => {
|
||||
sortingOrder.value = selectedSort.value.rule;
|
||||
// reset paging
|
||||
currentPage.value = 1;
|
||||
pageCursors.value = [];
|
||||
nextPageToken.value = null;
|
||||
fetchPage(1);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
return {
|
||||
compdata,
|
||||
currentItems,
|
||||
currentPage,
|
||||
totalPages,
|
||||
pageNumbers,
|
||||
selectedItems,
|
||||
showSelectBoxes,
|
||||
selectedSort,
|
||||
sortOptions,
|
||||
sortingOrder,
|
||||
userRole,
|
||||
isLoading,
|
||||
loadingMessage,
|
||||
hasMore, // <-- new
|
||||
loadMore,
|
||||
onToggleSelect,
|
||||
onDeleteSelected,
|
||||
onGoToPage,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
};
|
||||
}
|
||||
33
bobu/app/composables/useEditor.ts
Normal file
33
bobu/app/composables/useEditor.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// composables/useEditor.ts
|
||||
import { shallowRef } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import type ClassicEditor from '@ckeditor/ckeditor5-build-classic';
|
||||
import loadCKEditorFunction from '@/utils/ckeditorUtils';
|
||||
|
||||
export const useEditor = () => {
|
||||
const editorConstructor: Ref<typeof ClassicEditor | null> = shallowRef(null);
|
||||
const ckeditorComponent: Ref<Component | null> = shallowRef(null);
|
||||
const loadEditor = async () => {
|
||||
try {
|
||||
const ckInstances = await loadCKEditorFunction();
|
||||
editorConstructor.value = ckInstances.editor; // Assign the constructor
|
||||
ckeditorComponent.value = ckInstances.ckeditor; // Assign the Vue component
|
||||
console.log(
|
||||
'Editor constructor and component loaded:',
|
||||
!!editorConstructor.value,
|
||||
!!ckeditorComponent.value
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('CKEditor load failed:', err);
|
||||
// Reset refs on failure
|
||||
editorConstructor.value = null;
|
||||
ckeditorComponent.value = null;
|
||||
}
|
||||
};
|
||||
return {
|
||||
editor: editorConstructor,
|
||||
ckeditor: ckeditorComponent,
|
||||
loadEditor,
|
||||
};
|
||||
};
|
||||
27
bobu/app/composables/useWaitForAuth.ts
Normal file
27
bobu/app/composables/useWaitForAuth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// composables/useWaitForAuth.ts
|
||||
import { getAuth } from 'firebase/auth';
|
||||
|
||||
export const useWaitForAuth = async (): Promise<void> => {
|
||||
// Skip when Nuxt is rendering on the server (or in build)
|
||||
if (import.meta.server) return;
|
||||
|
||||
const auth = getAuth();
|
||||
|
||||
// If already signed‑in, nothing to wait for
|
||||
if (auth.currentUser) return;
|
||||
|
||||
// Otherwise wait (or timeout after 5 s)
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
const off = auth.onAuthStateChanged((user) => {
|
||||
if (user) {
|
||||
off();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}),
|
||||
new Promise<void>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Auth timeout')), 5000)
|
||||
),
|
||||
]);
|
||||
};
|
||||
46
bobu/app/data/assets.ts
Normal file
46
bobu/app/data/assets.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export const COLORSELECTOR_BG = {
|
||||
Light: '/assets/img/menu/Toggle_Dark.jpg',
|
||||
Dark: '/assets/img//menu/Toggle_Light.jpg',
|
||||
Sun: '/assets/img/menu/Sun.png',
|
||||
Moon: '/assets/img/menu/Moon.png',
|
||||
};
|
||||
|
||||
export const LOGOS = {
|
||||
White: '/assets/img/logo/BOBU_LOGO_WHITE.webp',
|
||||
RedGaro: '/assets/img/logo/Garo_Red.webp',
|
||||
Red: '/assets/img/logo/BOBU_LOGO_RED.webp',
|
||||
WhiteGaro: '/assets/img/logo/Garo_White.webp',
|
||||
};
|
||||
export const ABOUT_IMAGES = {
|
||||
manos: '/assets/img/shoot.jpg',
|
||||
ceoQuote: '/assets/img/shoot2.jpg',
|
||||
};
|
||||
export const TEAM_IMAGES = {
|
||||
junho: '/assets/img/placeholder_user.jpg',
|
||||
juhye: '/assets/img/placeholder_user.jpg',
|
||||
eonsu: '/assets/img/placeholder_user.jpg',
|
||||
seyoung: '/assets/img/placeholder_user.jpg',
|
||||
};
|
||||
export const MAIN = {
|
||||
background: '/assets/img/Background.webp',
|
||||
title: '/assets/img/BackgroundTitle.webp',
|
||||
};
|
||||
|
||||
export const MAIN_IMAGES = {
|
||||
main1: '/assets/img/main/main1.webp',
|
||||
main2: '/assets/img/main/main2.webp',
|
||||
main3: '/assets/img/main/main3.webp',
|
||||
};
|
||||
|
||||
export const MAIN2_IMAGES = {
|
||||
main1: '/assets/img/main/2_1_resized.webp',
|
||||
main2: '/assets/img/main/2_2.webp',
|
||||
main3: '/assets/img/main/2_3_resized.webp',
|
||||
main4: '/assets/img/main/2_4.webp',
|
||||
};
|
||||
|
||||
export const SOCIAL_IMAGES = {
|
||||
naver: '/assets/img/logo/naver_resized.jpg',
|
||||
instagram: '/assets/img/logo/instagram.webp',
|
||||
kakao: '/assets/img/logo/kakao_resized.jpg',
|
||||
};
|
||||
155
bobu/app/data/config.ts
Normal file
155
bobu/app/data/config.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// @/data/config.ts
|
||||
|
||||
export const BASE_NAV_ITEMS = [
|
||||
{ name: '소개', href: '/about', icon: ['fas', 'info-circle'] },
|
||||
{ name: '공유 오피스 예약', href: '/office', icon: ['fas', 'calendar-alt'] },
|
||||
{
|
||||
name: '워크케이션 프로그램',
|
||||
href: '/',
|
||||
icon: ['fas', 'umbrella-beach'],
|
||||
},
|
||||
{ name: '쇼핑하기', href: '/shop', icon: ['fas', 'shopping-bag'] },
|
||||
{ name: '문의하기', href: '/contact', icon: ['fas', 'envelope'] },
|
||||
{
|
||||
name: '와디즈 펀딩 참여자',
|
||||
href: '/wadiz',
|
||||
icon: ['fas', 'star'],
|
||||
color: 'red',
|
||||
},
|
||||
|
||||
// ...maybe { name: '와디즈 참여자', href: '/landing/wadiz' }
|
||||
// …
|
||||
{ name: 'CMS', href: '/cms', requiredRole: 6, icon: ['fas', 'cog'] },
|
||||
];
|
||||
export const SOCIAL_LINKS = {
|
||||
naver: 'https://naver.me/GDauV41H',
|
||||
instagram: 'https://www.instagram.com/bobu_0_0/#',
|
||||
kakao: 'https://pf.kakao.com/_abcdefg',
|
||||
};
|
||||
|
||||
export const companyInfo = {
|
||||
name: '노마드보부', // Or the official name from registration
|
||||
registrationNumber: '693-82-00244',
|
||||
president: '배현일',
|
||||
address: '강원 정선군 정선읍 정선로 1324 2층',
|
||||
phone: '0507-1353-1868',
|
||||
fax: '0507-1353-1868',
|
||||
email: 'manoscoop@naver.com',
|
||||
copyYear: new Date().getFullYear(), // Automatically get current year
|
||||
};
|
||||
// Upload Settings
|
||||
const MAX_FILE_SIZE_MB = 20;
|
||||
const MAX_TOTAL_FILES = 10;
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/jpg',
|
||||
];
|
||||
const ALLOWED_VIDEO_TYPES = [
|
||||
'video/mp4',
|
||||
'video/avi',
|
||||
'video/mpeg',
|
||||
'video/quicktime',
|
||||
'video/x-ms-wmv',
|
||||
];
|
||||
export const ALLOWED_DOCUMENT_TYPES = [
|
||||
// Word Documents
|
||||
'application/msword', // .doc
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
|
||||
// Excel Sheets
|
||||
'application/vnd.ms-excel', // .xls
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
||||
// PowerPoint Presentations
|
||||
'application/vnd.ms-powerpoint', // .ppt
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx
|
||||
// PDFs
|
||||
'application/pdf',
|
||||
// Hangul Word Processor (Korean)
|
||||
'application/x-hwp', // .hwp (standardized older type)
|
||||
'application/x-hwpx', // .hwpx (newer format)
|
||||
];
|
||||
|
||||
const ALLOWED_FILE_TYPES = [
|
||||
...ALLOWED_IMAGE_TYPES,
|
||||
...ALLOWED_DOCUMENT_TYPES,
|
||||
...ALLOWED_VIDEO_TYPES,
|
||||
];
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
|
||||
// User Role Settings
|
||||
export const ROLE_THRESHOLD = {
|
||||
MASTER: 9,
|
||||
ADMIN: 7,
|
||||
MANAGER: 5,
|
||||
TEACHER: 3,
|
||||
USER: 1,
|
||||
} as const;
|
||||
|
||||
// Utility Functions
|
||||
const isImageFile = (file: File): boolean =>
|
||||
ALLOWED_IMAGE_TYPES.includes(file.type);
|
||||
const isVideoFile = (file: File): boolean =>
|
||||
ALLOWED_VIDEO_TYPES.includes(file.type);
|
||||
const isDocumentFile = (file: File): boolean =>
|
||||
ALLOWED_DOCUMENT_TYPES.includes(file.type);
|
||||
|
||||
const isValidFile = (file: File): boolean => {
|
||||
return (
|
||||
ALLOWED_FILE_TYPES.includes(file.type) && file.size <= MAX_FILE_SIZE_BYTES
|
||||
);
|
||||
};
|
||||
|
||||
const getInvalidFileReasonKey = (file: File): string | null => {
|
||||
if (!ALLOWED_FILE_TYPES.includes(file.type)) return 'upload.invalid_type';
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) return 'upload.exceeds_size';
|
||||
return null;
|
||||
};
|
||||
|
||||
// Error Messages
|
||||
const UploadErrorMessages: Record<string, string> = {
|
||||
'upload.invalid_type': '허용되지 않은 파일 형식입니다.',
|
||||
'upload.exceeds_size': `파일 크기는 ${MAX_FILE_SIZE_MB}MB 이하만 가능합니다.`,
|
||||
};
|
||||
//Course Categories
|
||||
export const CourseCategories = [
|
||||
'동료지원가 양성과정',
|
||||
'정신건강 바로알기',
|
||||
'회복으로 가는 길',
|
||||
// '연주',
|
||||
// '실용음악',
|
||||
// '국악',
|
||||
// '융합교육',
|
||||
// '프로그램'
|
||||
] as const;
|
||||
export const ProgramCategories = [
|
||||
'자격증 과정',
|
||||
'일반 과정',
|
||||
'심화 과정',
|
||||
] as const;
|
||||
// Video Providers
|
||||
export const VideoProviders = ['youtube', 'vimeo', 'unknown'] as const;
|
||||
|
||||
// Export
|
||||
export const UploadSettings = {
|
||||
MAX_FILE_SIZE_MB,
|
||||
MAX_TOTAL_FILES,
|
||||
MAX_FILE_SIZE_BYTES,
|
||||
ALLOWED_FILE_TYPES,
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
ALLOWED_VIDEO_TYPES,
|
||||
ALLOWED_DOCUMENT_TYPES,
|
||||
ALLOWED_EXTENSIONS: ALLOWED_FILE_TYPES.join(','),
|
||||
isImageFile,
|
||||
isVideoFile,
|
||||
isDocumentFile,
|
||||
isValidFile,
|
||||
getInvalidFileReasonKey,
|
||||
UploadErrorMessages,
|
||||
};
|
||||
export type CourseCategory = (typeof CourseCategories)[number];
|
||||
export type ProgramCategory = (typeof ProgramCategories)[number];
|
||||
export type VideoProvider = (typeof VideoProviders)[number];
|
||||
340
bobu/app/data/types.ts
Normal file
340
bobu/app/data/types.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import type { Timestamp, FieldValue } from '@firebase/firestore';
|
||||
import type { FunctionalComponent } from 'vue';
|
||||
import type { HTMLAttributes, VNodeProps } from 'vue';
|
||||
//FIrebase
|
||||
import type { FirebaseApp } from 'firebase/app';
|
||||
import type { Auth, RecaptchaVerifier } from 'firebase/auth';
|
||||
import type { Firestore, CollectionReference } from 'firebase/firestore';
|
||||
import type { FirebaseStorage } from 'firebase/storage';
|
||||
import type { Functions } from 'firebase/functions';
|
||||
import type { Analytics } from 'firebase/analytics';
|
||||
import type { CourseCategory, ProgramCategory, VideoProvider } from './config';
|
||||
|
||||
export interface FirebasePlugin {
|
||||
firebaseApp: FirebaseApp;
|
||||
auth: Auth;
|
||||
db: Firestore;
|
||||
storage: FirebaseStorage;
|
||||
functions: Functions;
|
||||
analytics: Analytics;
|
||||
usersCollection: CollectionReference;
|
||||
faqboardsCollection: CollectionReference;
|
||||
countersCollection: CollectionReference;
|
||||
attendsCollection: CollectionReference;
|
||||
noticesCollection: CollectionReference;
|
||||
videosCollection: CollectionReference;
|
||||
quizzesCollection: CollectionReference;
|
||||
coursesCollection: CollectionReference;
|
||||
progressesCollection: CollectionReference;
|
||||
usergroupsCollection: CollectionReference;
|
||||
surveysCollection: CollectionReference;
|
||||
questionsCollection: CollectionReference;
|
||||
programsCollection: CollectionReference;
|
||||
headersCollection: CollectionReference;
|
||||
certificatesCollection: CollectionReference;
|
||||
projectsCollection: CollectionReference;
|
||||
signInWithGoogle: () => Promise<any>;
|
||||
createRecaptchaVerifier: (elementId: string) => RecaptchaVerifier;
|
||||
}
|
||||
export interface FirebaseConfig {
|
||||
apiKey: string;
|
||||
authDomain: string;
|
||||
projectId: string;
|
||||
storageBucket: string;
|
||||
messagingSenderId: string;
|
||||
appId: string;
|
||||
}
|
||||
// Management Page
|
||||
|
||||
//config
|
||||
export type ResizedUrls = {
|
||||
w200?: string;
|
||||
w500?: string;
|
||||
};
|
||||
|
||||
// Management Page
|
||||
export interface NavigationItem {
|
||||
name: string;
|
||||
route: any;
|
||||
icon: any; // Use a more specific type if available.
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
// Board : Elements
|
||||
|
||||
export type FileItem = {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
export type ThumbnailItem = FileItem & {
|
||||
fileName?: string;
|
||||
originalUrl?: string;
|
||||
resizedUrls?: ResizedUrls;
|
||||
};
|
||||
export interface UploadFilesOptions {
|
||||
newBoardData: BoardItem;
|
||||
newThumbnail: File | null;
|
||||
newFiles: File[];
|
||||
oldThumbnailUrl?: string;
|
||||
oldFiles?: FileItem[];
|
||||
deleteThumbnail?: boolean;
|
||||
deleteFileIndexes?: number[];
|
||||
currentBoard: string; // which indices of old files to delete
|
||||
}
|
||||
|
||||
export interface ThumbnailData {
|
||||
input: Ref<File | null>;
|
||||
preview: Ref<string>;
|
||||
deleteTrigger: Ref<boolean>;
|
||||
oldPreviewState: Ref<boolean>;
|
||||
uploadState: Ref<boolean>;
|
||||
previewState: Ref<boolean>;
|
||||
displayedName: Ref<string>;
|
||||
}
|
||||
|
||||
export interface UploadFileData {
|
||||
input: File | null;
|
||||
preview: string;
|
||||
deleteTrigger: boolean;
|
||||
oldPreviewState: boolean;
|
||||
uploadState: boolean;
|
||||
previewState: boolean;
|
||||
displayedName: string;
|
||||
}
|
||||
|
||||
export interface UploadsData {
|
||||
thumbnail: ThumbnailData;
|
||||
files: UploadFileData[];
|
||||
}
|
||||
// Board : Finally
|
||||
export type BoardItem = {
|
||||
docId: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
announcement?: boolean;
|
||||
ishidden?: boolean;
|
||||
boards_number: number;
|
||||
created: Timestamp | string | FieldValue;
|
||||
name?: string;
|
||||
files?: FileItem[];
|
||||
thumbnail?: ThumbnailItem;
|
||||
|
||||
// add other properties if needed
|
||||
};
|
||||
// Board : Types
|
||||
export interface ProjectBoard extends BoardItem {
|
||||
subtitle: string;
|
||||
displayDate?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export interface QuizBoard extends BoardItem {
|
||||
question: string;
|
||||
answers: {
|
||||
text: string;
|
||||
correct: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
url: string;
|
||||
duration: number;
|
||||
provider: VideoProvider;
|
||||
vimeoId?: string;
|
||||
}
|
||||
export interface VideoBoard extends BoardItem {
|
||||
video: VideoInfo;
|
||||
}
|
||||
export interface CourseBoard extends BoardItem {
|
||||
category: CourseCategory;
|
||||
isStrict: boolean;
|
||||
courseElements?: CourseElement[];
|
||||
headline?: string;
|
||||
video?: VideoInfo;
|
||||
expiresWhen?: string;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
// Board : Types Elements
|
||||
export interface CourseElement {
|
||||
docId: string;
|
||||
order: number;
|
||||
title: string;
|
||||
type: CourseElementType;
|
||||
duration?: number;
|
||||
}
|
||||
export interface CategoryItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
export type CourseElementType = 'video' | 'quiz' | 'docs';
|
||||
|
||||
//Program
|
||||
export interface CourseInProgram {
|
||||
docId: string;
|
||||
title: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface ProgramBoard extends BoardItem {
|
||||
courses: CourseInProgram[];
|
||||
category: ProgramCategory;
|
||||
isStrict: boolean;
|
||||
headline?: string;
|
||||
video?: VideoInfo;
|
||||
expiresWhen?: string;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
//past
|
||||
|
||||
export interface ProgramWithProgress extends ProgramBoard {
|
||||
progressPercentage: number;
|
||||
completed: boolean;
|
||||
lastCourseProgressDocId: string | null;
|
||||
}
|
||||
|
||||
//Course
|
||||
export interface Coursecreators {
|
||||
id: number;
|
||||
name: string;
|
||||
route: { name: string };
|
||||
initial: string;
|
||||
current: boolean;
|
||||
}
|
||||
export type CourseNavItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
route: { name: string };
|
||||
current: boolean;
|
||||
docId: string;
|
||||
lessonFinish: boolean;
|
||||
type: string;
|
||||
progressId: string;
|
||||
courseId: string;
|
||||
};
|
||||
|
||||
export interface Lesson {
|
||||
docId: string;
|
||||
order: number;
|
||||
title: string;
|
||||
type: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
//Progress
|
||||
export type Progress = {
|
||||
docId: string;
|
||||
boards_number: number;
|
||||
courseCategory: string;
|
||||
courseId: string;
|
||||
created: Timestamp;
|
||||
lessons: LessonProgress[]; // ✅ Fixed here
|
||||
userId: string;
|
||||
courseFinish: Timestamp;
|
||||
courseTitle: string;
|
||||
userSurveyed?: boolean;
|
||||
ishidden?: boolean;
|
||||
finishedDate?: Timestamp;
|
||||
complete?: boolean;
|
||||
extended?: boolean;
|
||||
};
|
||||
export type LessonProgress = {
|
||||
currentTime?: number;
|
||||
docId: string;
|
||||
duration?: number;
|
||||
lessonFinish: boolean;
|
||||
order: number;
|
||||
type: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export interface ProgressWithRuntimeData
|
||||
extends Omit<Progress, 'courseTitle' | 'courseCategory' | 'percentage'> {
|
||||
totalDuration?: number;
|
||||
currentTime?: number;
|
||||
percentage?: number;
|
||||
courseTitle: string;
|
||||
category?: string;
|
||||
courseThumbnail?: string;
|
||||
courseHeadline?: string;
|
||||
courseCategory: string;
|
||||
}
|
||||
|
||||
export interface UserNavigation {
|
||||
name: string;
|
||||
route: { name: string };
|
||||
onClick?: () => void; // Add an optional onClick property for handling the click event
|
||||
}
|
||||
export interface CreateProgressResponse {
|
||||
docId: string;
|
||||
}
|
||||
|
||||
export interface CertificateResponse {
|
||||
status: 'success' | 'failed';
|
||||
message?: {
|
||||
imageUrl: string;
|
||||
pdfUrl: string;
|
||||
uniqueId: string;
|
||||
};
|
||||
}
|
||||
|
||||
//Nav
|
||||
export type SortOption = {
|
||||
sortId: number;
|
||||
name: string;
|
||||
rule: OrderByDirection;
|
||||
};
|
||||
|
||||
export type OrderByDirection = 'asc' | 'desc';
|
||||
|
||||
//user
|
||||
export type UserRegistrationValues = {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
membership:
|
||||
| '당사자'
|
||||
| '동료지원가'
|
||||
| '당사자 지인 또는 가족'
|
||||
| '기관종사자'
|
||||
| '일반시민';
|
||||
tos: boolean;
|
||||
};
|
||||
export interface LoginValues {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface VimeoPlaybackData {
|
||||
duration: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
//Tests
|
||||
export interface CompData {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
name: string;
|
||||
description: string;
|
||||
href: string;
|
||||
icon: FunctionalComponent<HTMLAttributes & VNodeProps, any>;
|
||||
}
|
||||
|
||||
export interface CallToAction {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: FunctionalComponent<HTMLAttributes & VNodeProps, any>;
|
||||
}
|
||||
//Funtion Types
|
||||
|
||||
export type UpdateDisplayedItemsFn = (
|
||||
currentItems: Ref<BoardItem[]>,
|
||||
displayedItems: Ref<BoardItem[]>,
|
||||
currentPage: Ref<number>,
|
||||
itemsPerPage: number
|
||||
) => void;
|
||||
34
bobu/app/layouts/cms/upload-header.vue
Normal file
34
bobu/app/layouts/cms/upload-header.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<!-- Mobile sidebar -->
|
||||
<AppCmsSidebar :open="cms.sidebarOpen" @close="cms.toggleSidebar()" />
|
||||
<!-- Topbar + Slots -->
|
||||
<div class="lg:pl-72">
|
||||
<!-- Topbar -->
|
||||
<AppCmsTopbar @toggle-sidebar="cms.toggleSidebar()" />
|
||||
<!-- Main Slots-->
|
||||
<main class="py-10">
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppCmsSidebar from '@/components/header/CmsSidebar.vue';
|
||||
import AppCmsTopbar from '@/components/header/CmsTopbar.vue';
|
||||
import { useCmsStore } from '@/stores/useCmsStore';
|
||||
|
||||
const cms = useCmsStore();
|
||||
// populate your actions once (maybe on mount)
|
||||
cms.setActions({
|
||||
actions: [
|
||||
{ id: 'publish', label: 'Publish Now' },
|
||||
{ id: 'draft', label: 'Save Draft' },
|
||||
{ id: 'queue', label: 'Queue Upload' },
|
||||
],
|
||||
mainId: 'publish',
|
||||
});
|
||||
</script>
|
||||
23
bobu/app/layouts/default.vue
Normal file
23
bobu/app/layouts/default.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
<!-- Header -->
|
||||
<app-main-header />
|
||||
<!-- Main page content -->
|
||||
<main>
|
||||
<NuxtPage />
|
||||
</main>
|
||||
<app-main-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppMainHeader from '@/components/MainHeader.vue';
|
||||
import AppMainFooter from '@/components/MainFooter.vue';
|
||||
import useUserStore from '@/stores/user';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
onMounted(() => {
|
||||
userStore.initializeListener();
|
||||
});
|
||||
</script>
|
||||
17
bobu/app/middleware/auth.global.ts
Normal file
17
bobu/app/middleware/auth.global.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// middleware/auth.global.ts
|
||||
import { useUserStore } from '@/stores/user';
|
||||
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
// const userStore = useUserStore();
|
||||
// // 1️⃣ skip if requiresAuth is explicitly false
|
||||
// if (to.meta.requiresAuth === false) {
|
||||
// return;
|
||||
// }
|
||||
// // 2️⃣ guard
|
||||
// if (!userStore.userLoggedIn) {
|
||||
// return navigateTo('/login');
|
||||
// }
|
||||
// if (to.meta.requiresAdmin && userStore.userRole < 10) {
|
||||
// return navigateTo('/unauthorized');
|
||||
// }
|
||||
});
|
||||
8
bobu/app/pages/about.vue
Normal file
8
bobu/app/pages/about.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<AboutSection1 />
|
||||
<AboutSection3 />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
137
bobu/app/pages/cms/[docId].vue
Normal file
137
bobu/app/pages/cms/[docId].vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-board-header :h2data="compData.title"></app-board-header>
|
||||
<section class="bg-white dark:bg-gray-900 py-10 w-full min-h-[85vh]">
|
||||
<div v-if="!toggleEdit" class="mx-auto max-w-5xl w-full py-4 md:px-6">
|
||||
<app-board-body :board="board" />
|
||||
<!-- buttons -->
|
||||
<app-board-action
|
||||
:listRouteName="compData.listRouteName"
|
||||
:uploadRouteName="compData.uploadRouteName"
|
||||
@edit="toggleEditBoard"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
<app-upload-project-form
|
||||
v-if="toggleEdit"
|
||||
:isEdit="true"
|
||||
:board="board"
|
||||
@update-success="handleUpdateSuccess"
|
||||
/>
|
||||
</section>
|
||||
<app-loading-overlay
|
||||
:isLoading="in_submission"
|
||||
:loadingMessage="deletingMessage"
|
||||
></app-loading-overlay>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
const { $firebase } = useNuxtApp();
|
||||
import useUserStore from '@/stores/user';
|
||||
import AppBoardHeader from '@/components/boards/BoardHeader.vue';
|
||||
import AppBoardAction from '@/components/boards/BoardAction.vue';
|
||||
import AppBoardBody from '@/components/boards/BoardBody.vue';
|
||||
import AppLoadingOverlay from '@/components/LoadingOverlay.vue';
|
||||
import { loadBoardDetails, deleteSingle } from '@/utils/boardUtils';
|
||||
import type { ProjectBoard, OrderByDirection } from '@/types';
|
||||
const storage = $firebase.storage;
|
||||
|
||||
//***Variables, Things you need to change****)
|
||||
const projectsCollection = $firebase.projectsCollection;
|
||||
import AppUploadProjectForm from '@/components/boards/project/UploadProjectForm.vue';
|
||||
const currentCollection = projectsCollection;
|
||||
const currentBoardRouteName = '/cms';
|
||||
const compData = {
|
||||
title: 'Projects | CMS',
|
||||
routeName: '/cms/[docId]',
|
||||
itemsPerPage: 20,
|
||||
defaultSort: 'desc' as OrderByDirection,
|
||||
listRouteName: '/cms',
|
||||
uploadRouteName: '/cms/upload',
|
||||
};
|
||||
|
||||
//
|
||||
const in_submission = ref(false);
|
||||
const deletingMessage = '삭제 중! 잠시만 기다려주세요...';
|
||||
|
||||
//Reactive variables
|
||||
const router = useRouter();
|
||||
const route = useRoute(); // Access the route object
|
||||
const docId = computed(() => route.params.docId as string);
|
||||
|
||||
const board = ref<ProjectBoard>({
|
||||
docId: '',
|
||||
userId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
subtitle: '',
|
||||
announcement: false,
|
||||
ishidden: false,
|
||||
boards_number: 0,
|
||||
created: '',
|
||||
boardState: {
|
||||
state: 'pending',
|
||||
error: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userRole = computed(() => userStore.userRole);
|
||||
|
||||
const toggleEdit = ref(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
const result = await deleteSingle(board.value, currentCollection, storage);
|
||||
if (result) {
|
||||
router.push(currentBoardRouteName); // path string works directly in Nuxt3
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSuccess = async () => {
|
||||
await loadBoardDetails(
|
||||
board.value.docId,
|
||||
board, // ref<BoardItem>
|
||||
currentCollection,
|
||||
router,
|
||||
currentBoardRouteName,
|
||||
userRole // Ref<number>
|
||||
);
|
||||
|
||||
toggleEdit.value = false;
|
||||
};
|
||||
|
||||
const toggleEditBoard = () => {
|
||||
toggleEdit.value = !toggleEdit.value;
|
||||
};
|
||||
onMounted(() => {
|
||||
if (docId.value) {
|
||||
loadBoardDetails(
|
||||
docId.value,
|
||||
board,
|
||||
currentCollection,
|
||||
router,
|
||||
currentBoardRouteName,
|
||||
userRole
|
||||
);
|
||||
}
|
||||
});
|
||||
watch(docId, (newId) => {
|
||||
if (!newId) return;
|
||||
loadBoardDetails(
|
||||
newId,
|
||||
board,
|
||||
currentCollection,
|
||||
router,
|
||||
currentBoardRouteName,
|
||||
userRole
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
97
bobu/app/pages/cms/index.vue
Normal file
97
bobu/app/pages/cms/index.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div>
|
||||
<section>
|
||||
<app-boards-header
|
||||
:h2data="compdata.title"
|
||||
v-model="selectedSort"
|
||||
:sortOptions="sortOptions"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<app-board-list
|
||||
v-model="selectedSort"
|
||||
:title="compdata.title"
|
||||
:sortOptions="sortOptions"
|
||||
:userRole="userRole"
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
:page-numbers="pageNumbers"
|
||||
:show-select-boxes="showSelectBoxes"
|
||||
:uploadRoute="currentUploadRoute"
|
||||
@toggle-select-boxes="showSelectBoxes = !showSelectBoxes"
|
||||
@delete-selected="onDeleteSelected"
|
||||
@go-to-page="onGoToPage"
|
||||
@prev-page="onPrevPage"
|
||||
@next-page="onNextPage"
|
||||
:isLoading="isLoading"
|
||||
:loadingMessage="loadingMessage"
|
||||
>
|
||||
<template #list>
|
||||
<app-board-list-single
|
||||
v-for="item in currentItems"
|
||||
:key="item.docId"
|
||||
:userId="item.userId"
|
||||
:item="item"
|
||||
:showSelectBox="showSelectBoxes"
|
||||
:selected="selectedItems.includes(item)"
|
||||
@select="onToggleSelect(item)"
|
||||
:iconName="['fas', 'bell']"
|
||||
:routeName="currentBoardRouteName"
|
||||
/>
|
||||
</template>
|
||||
</app-board-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
prerender: false,
|
||||
});
|
||||
|
||||
import LoadingSection from '@/components/LoadingSection.vue';
|
||||
import AppBoardsHeader from '@/components/boards/BoardHeader.vue';
|
||||
import AppBoardList from '@/components/boards/BoardList.vue';
|
||||
import AppBoardListSingle from '@/components/boards/BoardListSingle.vue';
|
||||
import type { OrderByDirection, BoardAccessMode } from '@/types';
|
||||
|
||||
// custom settings
|
||||
const access: BoardAccessMode = 'admin';
|
||||
const currentCollection = 'projects';
|
||||
const currentUploadRoute = '/cms/upload';
|
||||
const currentBoardRouteName = '/cms';
|
||||
const compData = {
|
||||
title: '공지사항',
|
||||
itemsPerPage: 20,
|
||||
defaultSort: 'desc' as OrderByDirection,
|
||||
};
|
||||
const loading = '게시물을 불러오는 중입니다...';
|
||||
|
||||
// Composable
|
||||
import { useBoardList } from '@/composables/useBoardList';
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
loadingMessage,
|
||||
compdata,
|
||||
currentItems,
|
||||
currentPage,
|
||||
pageNumbers,
|
||||
selectedSort,
|
||||
sortOptions,
|
||||
userRole,
|
||||
totalPages,
|
||||
selectedItems,
|
||||
showSelectBoxes,
|
||||
onToggleSelect,
|
||||
onDeleteSelected,
|
||||
onGoToPage,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
} = useBoardList(currentCollection, {
|
||||
title: compData.title,
|
||||
itemsPerPage: compData.itemsPerPage,
|
||||
defaultSort: compData.defaultSort,
|
||||
access: access,
|
||||
loadingMessage: loading,
|
||||
});
|
||||
</script>
|
||||
38
bobu/app/pages/cms/upload.vue
Normal file
38
bobu/app/pages/cms/upload.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div>
|
||||
<ClientOnly>
|
||||
<AppUploadProjectForm />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useHeaderStateStore } from '@/stores/headerStateStore';
|
||||
import AppUploadProjectForm from '@/components/boards/project/UploadProjectForm.vue';
|
||||
|
||||
const headerStore = useHeaderStateStore();
|
||||
const route = useRoute(); // Get current route information
|
||||
const isCmsHeader = 'cms-upload-header';
|
||||
|
||||
onMounted(() => {
|
||||
if (headerStore.activeLayout !== isCmsHeader) {
|
||||
headerStore.setActiveLayout(isCmsHeader);
|
||||
console.log(`Layout set to '${isCmsHeader}' by: ${route.path}`);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeRouteLeave((to, from, next) => {
|
||||
if (to.path && !to.path.startsWith('/cms/')) {
|
||||
headerStore.setActiveLayout('default');
|
||||
console.log(
|
||||
`Layout set to 'default' by: ${from.path} (navigating to non-${isCmsHeader} area: ${to.path})`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Staying in ${isCmsHeader} area or navigating to another ${isCmsHeader} page from: ${from.path}. Layout remains 'cms-upload-header'.`
|
||||
);
|
||||
}
|
||||
next();
|
||||
});
|
||||
</script>
|
||||
50
bobu/app/pages/construction.vue
Normal file
50
bobu/app/pages/construction.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="construction-page">
|
||||
<div class="content">
|
||||
<i class="fas fa-tools fa-5x"></i>
|
||||
<h1>현재 사이트 보수중입니다</h1>
|
||||
<p>
|
||||
Our website is currently undergoing scheduled maintenance. We should be
|
||||
back shortly. Thank you for your patience.
|
||||
</p>
|
||||
<p>during this time, you can contact us at</p>
|
||||
<p>02-830-1592 / manoscoop@naver.com</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
<style scoped>
|
||||
@import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css");
|
||||
|
||||
.construction-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 600px;
|
||||
padding: 20px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 10px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.content i {
|
||||
color: #6c757d;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.content p {
|
||||
font-size: 1.25rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
6
bobu/app/pages/contact.vue
Normal file
6
bobu/app/pages/contact.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<ContactUs />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
12
bobu/app/pages/index.vue
Normal file
12
bobu/app/pages/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
<!-- Featured Projects Section -->
|
||||
<FeaturesCarousel />
|
||||
<AboutSection1 />
|
||||
<AboutSection3 />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MAIN } from '~/data/assets';
|
||||
</script>
|
||||
6
bobu/app/pages/login.vue
Normal file
6
bobu/app/pages/login.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<AdminLogin />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import AdminLogin from "~/components/auth/admin-login.vue";
|
||||
</script>
|
||||
143
bobu/app/pages/notice/[docId].vue
Normal file
143
bobu/app/pages/notice/[docId].vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-board-header :h2data="compData.title"></app-board-header>
|
||||
|
||||
<section class="bg-white dark:bg-gray-900 py-10 w-full min-h-[85vh]">
|
||||
<div v-if="!toggleEdit" class="mx-auto max-w-5xl w-full py-4 md:px-6">
|
||||
<app-board-body :board="board" />
|
||||
|
||||
<app-board-action
|
||||
:listRouteName="compData.listRouteName"
|
||||
:uploadRouteName="compData.uploadRouteName"
|
||||
@edit="toggleEditBoard"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<app-upload-notice-form
|
||||
v-if="toggleEdit"
|
||||
:isEdit="true"
|
||||
:board="board"
|
||||
@update-success="handleUpdateSuccess"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<app-loading-overlay
|
||||
:isLoading="in_submission"
|
||||
:loadingMessage="deletingMessage"
|
||||
></app-loading-overlay>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import useUserStore from '@/stores/user';
|
||||
import AppBoardHeader from '@/components/boards/BoardHeader.vue';
|
||||
import AppBoardAction from '@/components/boards/BoardAction.vue';
|
||||
import AppBoardBody from '@/components/boards/BoardBody.vue';
|
||||
import AppLoadingOverlay from '@/components/LoadingOverlay.vue';
|
||||
import { loadBoardDetails, deleteSingle } from '@/utils/boardUtils';
|
||||
import type { BoardItem, OrderByDirection } from '@/types';
|
||||
const { $firebase } = useNuxtApp();
|
||||
const storage = $firebase.storage;
|
||||
|
||||
//***Variables, Things you need to change****)
|
||||
const noticesCollection = $firebase.noticesCollection;
|
||||
import AppUploadNoticeForm from '@/components/boards/notice/UploadNoticeForm.vue';
|
||||
import AppUploadNotice from '~/pages/notice/upload.vue';
|
||||
const currentCollection = noticesCollection;
|
||||
const currentBoardRouteName = '/notice';
|
||||
const compData = {
|
||||
title: '공지사항 | NOTICE',
|
||||
routeName: '/notice/[docId]', // path to single notice view
|
||||
itemsPerPage: 20,
|
||||
defaultSort: 'desc' as OrderByDirection,
|
||||
listRouteName: '/notice',
|
||||
uploadRouteName: '/notice/upload',
|
||||
};
|
||||
|
||||
//
|
||||
const in_submission = ref(false);
|
||||
const deletingMessage = '삭제 중! 잠시만 기다려주세요...';
|
||||
|
||||
//Reactive variables
|
||||
const router = useRouter();
|
||||
const route = useRoute(); // Access the route object
|
||||
const docId = computed(() => route.params.docId as string);
|
||||
|
||||
const board = ref<BoardItem>({
|
||||
docId: '',
|
||||
userId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
announcement: false,
|
||||
ishidden: false,
|
||||
boards_number: 0,
|
||||
created: '',
|
||||
boardState: {
|
||||
state: 'pending',
|
||||
error: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userRole = computed(() => userStore.userRole);
|
||||
|
||||
const toggleEdit = ref(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
const result = await deleteSingle(board.value, currentCollection, storage);
|
||||
if (result) {
|
||||
router.push(currentBoardRouteName); // path string works directly in Nuxt3
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSuccess = async () => {
|
||||
await loadBoardDetails(
|
||||
board.value.docId,
|
||||
board, // ref<BoardItem>
|
||||
currentCollection,
|
||||
router,
|
||||
currentBoardRouteName,
|
||||
userRole // Ref<number>
|
||||
);
|
||||
|
||||
toggleEdit.value = false;
|
||||
};
|
||||
|
||||
const toggleEditBoard = () => {
|
||||
toggleEdit.value = !toggleEdit.value;
|
||||
};
|
||||
|
||||
watch(docId, (newId) => {
|
||||
if (!newId) return;
|
||||
console.log('newId', newId);
|
||||
loadBoardDetails(
|
||||
newId,
|
||||
board,
|
||||
currentCollection,
|
||||
router,
|
||||
currentBoardRouteName,
|
||||
userRole
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (docId.value) {
|
||||
loadBoardDetails(
|
||||
docId.value,
|
||||
board,
|
||||
currentCollection,
|
||||
router,
|
||||
currentBoardRouteName,
|
||||
userRole
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
92
bobu/app/pages/notice/index.vue
Normal file
92
bobu/app/pages/notice/index.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div>
|
||||
<section>
|
||||
<app-boards-header
|
||||
:h2data="compdata.title"
|
||||
v-model="selectedSort"
|
||||
:sortOptions="sortOptions"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<app-board-list
|
||||
v-model="selectedSort"
|
||||
:title="compdata.title"
|
||||
:sortOptions="sortOptions"
|
||||
:userRole="userRole"
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
:page-numbers="pageNumbers"
|
||||
:show-select-boxes="showSelectBoxes"
|
||||
:uploadRoute="currentUploadRoute"
|
||||
@toggle-select-boxes="showSelectBoxes = !showSelectBoxes"
|
||||
@delete-selected="onDeleteSelected"
|
||||
@go-to-page="onGoToPage"
|
||||
@prev-page="onPrevPage"
|
||||
@next-page="onNextPage"
|
||||
:isLoading="isLoading"
|
||||
:loadingMessage="loadingMessage"
|
||||
>
|
||||
<template #list>
|
||||
<app-board-list-single
|
||||
v-for="item in currentItems"
|
||||
:key="item.docId"
|
||||
:userId="item.userId"
|
||||
:item="item"
|
||||
:showSelectBox="showSelectBoxes"
|
||||
@select="onToggleSelect(item)"
|
||||
:iconName="['fas', 'bell']"
|
||||
:routeName="currentBoardRouteName"
|
||||
/>
|
||||
</template>
|
||||
</app-board-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppBoardsHeader from '@/components/boards/BoardHeader.vue';
|
||||
import AppBoardList from '@/components/boards/BoardList.vue';
|
||||
import AppBoardListSingle from '@/components/boards/BoardListSingle.vue';
|
||||
import type { OrderByDirection, BoardAccessMode } from '@/types';
|
||||
|
||||
//customize
|
||||
const access: BoardAccessMode = 'public';
|
||||
const currentCollection = 'notices';
|
||||
const currentUploadRoute = '/notice/upload';
|
||||
const currentBoardRouteName = '/notice';
|
||||
const compData = {
|
||||
title: '공지사항',
|
||||
itemsPerPage: 20,
|
||||
defaultSort: 'desc' as OrderByDirection,
|
||||
};
|
||||
const loading = '게시물을 불러오는 중입니다...';
|
||||
import { useBoardList } from '@/composables/useBoardList';
|
||||
|
||||
// destructure only the things you actually need
|
||||
const {
|
||||
isLoading,
|
||||
loadingMessage,
|
||||
compdata,
|
||||
currentItems,
|
||||
currentPage,
|
||||
pageNumbers,
|
||||
selectedSort,
|
||||
sortOptions,
|
||||
userRole,
|
||||
totalPages,
|
||||
selectedItems,
|
||||
showSelectBoxes,
|
||||
onToggleSelect,
|
||||
onDeleteSelected,
|
||||
onGoToPage,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
} = useBoardList(currentCollection, {
|
||||
title: compData.title,
|
||||
itemsPerPage: compData.itemsPerPage,
|
||||
defaultSort: compData.defaultSort,
|
||||
access: access,
|
||||
loadingMessage: loading,
|
||||
});
|
||||
|
||||
// no more fetchBoardsAndUpdateItems or onBeforeMount
|
||||
</script>
|
||||
9
bobu/app/pages/notice/upload.vue
Normal file
9
bobu/app/pages/notice/upload.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<app-upload-notice-form :isEdit="false" />
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppUploadNoticeForm from '@/components/boards/notice/UploadNoticeForm.vue';
|
||||
</script>
|
||||
7
bobu/app/pages/office.vue
Normal file
7
bobu/app/pages/office.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>공유 오피스 예약</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
2
bobu/app/pages/projects/[docId].vue
Normal file
2
bobu/app/pages/projects/[docId].vue
Normal file
@@ -0,0 +1,2 @@
|
||||
<template><div>hi</div></template>
|
||||
<script setup lang="ts"></script>
|
||||
81
bobu/app/pages/projects/index.vue
Normal file
81
bobu/app/pages/projects/index.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<section class="py-16 px-4 max-w-6xl mx-auto">
|
||||
<h1 class="text-3xl font-bold text-center mb-12">Our Film Productions</h1>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.docId"
|
||||
class="bg-white dark:bg-gray-800 rounded-xl overflow-hidden shadow hover:shadow-xl transition duration-300 flex flex-col"
|
||||
>
|
||||
<img
|
||||
v-if="project.thumbnail?.url"
|
||||
:src="project.thumbnail.url"
|
||||
:alt="project.title"
|
||||
class="w-full h-64 object-cover"
|
||||
/>
|
||||
<div class="p-6 flex flex-col justify-between flex-1">
|
||||
<h2 class="text-xl font-semibold mb-2">{{ project.title }}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||
{{ project.subtitle }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
v-if="project.docId"
|
||||
:to="`/projects/${project.docId}`"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline mt-auto"
|
||||
>
|
||||
Learn more →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-20">
|
||||
<h2 class="text-2xl font-bold mb-4">Have a project in mind?</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Let's bring your story to life. Contact us for a consultation or
|
||||
collaboration.
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/contact"
|
||||
class="inline-block bg-black text-white px-6 py-3 rounded-full hover:bg-gray-800 transition"
|
||||
>
|
||||
Get in Touch
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import LoadingSection from '@/components/LoadingSection.vue';
|
||||
import type { ProjectBoard } from '@/types';
|
||||
import { fetchBoardsFromFunction } from '@/utils/api/fetchBoardsFromFunction';
|
||||
|
||||
const projects = ref<ProjectBoard[]>([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const { items: boards } = await fetchBoardsFromFunction<ProjectBoard>(
|
||||
'projects',
|
||||
{
|
||||
access: 'public', // use new access‑control
|
||||
itemsPerPage: 100, // big enough to fetch them all
|
||||
sortOrder: 'desc',
|
||||
pageNumber: 1,
|
||||
}
|
||||
);
|
||||
|
||||
// keep only announcement (or whatever client‑side filter you need)
|
||||
projects.value = boards.filter((p) => p.announcement);
|
||||
} catch (err) {
|
||||
console.error('Error fetching projects:', err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchProjects);
|
||||
</script>
|
||||
7
bobu/app/pages/shop.vue
Normal file
7
bobu/app/pages/shop.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Shop</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
5
bobu/app/pages/unauthorized.vue
Normal file
5
bobu/app/pages/unauthorized.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Unauthorized</h1>
|
||||
</div>
|
||||
</template>
|
||||
43
bobu/app/pages/wadiz.vue
Normal file
43
bobu/app/pages/wadiz.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="isolate bg-white dark:bg-gray-900 px-6 py-12 sm:py-12 lg:px-8">
|
||||
<!-- Section -->
|
||||
<section
|
||||
class="isolate bg-white dark:bg-gray-900 px-6 py-16 sm:py-24 lg:px-8"
|
||||
>
|
||||
<div class="mx-auto max-w-2xl lg:max-w-4xl">
|
||||
<p
|
||||
class="text-center text-2xl font-extrabold text-indigo-500 dark:text-indigo-400"
|
||||
>
|
||||
와디즈 펀딩 참여자 발송 페이지
|
||||
</p>
|
||||
<figure class="mt-10">
|
||||
<blockquote
|
||||
class="text-center text-lg font-normal text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<p>안녕하세요, 주식회사 보부입니다.</p>
|
||||
<p>
|
||||
지난 와디즈에서 진행한 정선 백패킹 포레스트 관련한 예약 페이지
|
||||
입니다.
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
참여하신 분들은
|
||||
<strong class="text-gray-900 dark:text-gray-100"
|
||||
>6월 13일까지</strong
|
||||
>
|
||||
작성 부탁드립니다.
|
||||
</p>
|
||||
</blockquote>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Form -->
|
||||
<ClientOnly>
|
||||
<app-wadiz-form />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppWadizForm from '@/components/WadizForm.vue';
|
||||
</script>
|
||||
7
bobu/app/pages/workstation.vue
Normal file
7
bobu/app/pages/workstation.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>워크스테이션 프로그램</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
99
bobu/app/plugins/00_firebase.client.ts
Normal file
99
bobu/app/plugins/00_firebase.client.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { defineNuxtPlugin } from '#app';
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import {
|
||||
getAuth,
|
||||
signInWithPopup,
|
||||
GoogleAuthProvider,
|
||||
RecaptchaVerifier,
|
||||
signInWithPhoneNumber,
|
||||
} from 'firebase/auth';
|
||||
import { getFirestore, collection } from 'firebase/firestore';
|
||||
import { getStorage } from 'firebase/storage';
|
||||
import { getFunctions } from 'firebase/functions';
|
||||
import { getAnalytics } from 'firebase/analytics';
|
||||
//import types
|
||||
import type { FirebasePlugin } from '@/types';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: config.public.firebaseApiKey,
|
||||
authDomain: config.public.firebaseAuthDomain,
|
||||
projectId: config.public.firebaseProjectId,
|
||||
storageBucket: config.public.firebaseStorageBucket,
|
||||
messagingSenderId: config.public.firebaseMessagingSenderId,
|
||||
appId: config.public.firebaseAppId,
|
||||
};
|
||||
|
||||
const firebaseApp = initializeApp(firebaseConfig);
|
||||
const auth = getAuth(firebaseApp);
|
||||
auth.languageCode = 'kr';
|
||||
const db = getFirestore(firebaseApp);
|
||||
const storage = getStorage(firebaseApp);
|
||||
const functions = getFunctions(firebaseApp);
|
||||
const analytics = getAnalytics(firebaseApp);
|
||||
|
||||
// Collections
|
||||
const usersCollection = collection(db, 'users');
|
||||
const faqboardsCollection = collection(db, 'faqboards');
|
||||
const countersCollection = collection(db, 'counters');
|
||||
const attendsCollection = collection(db, 'attends');
|
||||
const noticesCollection = collection(db, 'notices');
|
||||
const videosCollection = collection(db, 'videos');
|
||||
const quizzesCollection = collection(db, 'quizzes');
|
||||
const coursesCollection = collection(db, 'courses');
|
||||
const progressesCollection = collection(db, 'progresses');
|
||||
const usergroupsCollection = collection(db, 'usergroups');
|
||||
const surveysCollection = collection(db, 'surveys');
|
||||
const questionsCollection = collection(db, 'questions');
|
||||
const programsCollection = collection(db, 'programs');
|
||||
const headersCollection = collection(db, 'headers');
|
||||
const certificatesCollection = collection(db, 'certificates');
|
||||
const projectsCollection = collection(db, 'projects');
|
||||
// Google Sign-In
|
||||
const provider = new GoogleAuthProvider();
|
||||
async function signInWithGoogle() {
|
||||
const result = await signInWithPopup(auth, provider);
|
||||
return result.user;
|
||||
}
|
||||
|
||||
// Recaptcha
|
||||
const createRecaptchaVerifier = (elementId: string) => {
|
||||
return new RecaptchaVerifier(auth, elementId, {
|
||||
size: 'invisible',
|
||||
callback: () => {
|
||||
// reCAPTCHA solved
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const firebase: FirebasePlugin = {
|
||||
firebaseApp,
|
||||
auth,
|
||||
db,
|
||||
storage,
|
||||
functions,
|
||||
analytics,
|
||||
usersCollection,
|
||||
faqboardsCollection,
|
||||
countersCollection,
|
||||
attendsCollection,
|
||||
noticesCollection,
|
||||
videosCollection,
|
||||
quizzesCollection,
|
||||
coursesCollection,
|
||||
progressesCollection,
|
||||
usergroupsCollection,
|
||||
surveysCollection,
|
||||
questionsCollection,
|
||||
programsCollection,
|
||||
headersCollection,
|
||||
certificatesCollection,
|
||||
projectsCollection,
|
||||
signInWithGoogle,
|
||||
createRecaptchaVerifier,
|
||||
};
|
||||
|
||||
nuxtApp.provide('firebase', firebase);
|
||||
});
|
||||
7
bobu/app/plugins/01_auth.client.ts
Normal file
7
bobu/app/plugins/01_auth.client.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// plugins/auth.client.ts
|
||||
import { useUserStore } from '@/stores/user';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const userStore = useUserStore();
|
||||
userStore.init(); // <-- does _all_ of the work now
|
||||
});
|
||||
11
bobu/app/plugins/fontawesome.ts
Normal file
11
bobu/app/plugins/fontawesome.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// plugins/fontawesome.ts
|
||||
import { defineNuxtPlugin } from '#app';
|
||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { fas } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
library.add(fas);
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component('FontAwesomeIcon', FontAwesomeIcon);
|
||||
});
|
||||
71
bobu/app/plugins/vee-validate.ts
Normal file
71
bobu/app/plugins/vee-validate.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// plugins/vee-validate.ts
|
||||
import { defineNuxtPlugin } from '#app';
|
||||
import { defineRule, configure, Form, Field, ErrorMessage } from 'vee-validate'; // Import components if registering globally
|
||||
import {
|
||||
required,
|
||||
min,
|
||||
max,
|
||||
alpha_spaces as alphaSpaces,
|
||||
email,
|
||||
min_value as minValue,
|
||||
max_value as maxValue,
|
||||
confirmed,
|
||||
not_one_of as excluded,
|
||||
} from '@vee-validate/rules';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
// --- Global Component Registration (Optional in Nuxt 3) ---
|
||||
// Nuxt 3 often uses auto-imports or explicit imports in components.
|
||||
// You *can* register them globally like this if you prefer:
|
||||
// nuxtApp.vueApp.component('VeeForm', Form);
|
||||
// nuxtApp.vueApp.component('VeeField', Field);
|
||||
// nuxtApp.vueApp.component('ErrorMessage', ErrorMessage);
|
||||
// If you don't register globally, import them in your .vue files:
|
||||
// import { Form as VeeForm, Field as VeeField, ErrorMessage } from 'vee-validate';
|
||||
|
||||
// --- Define Validation Rules ---
|
||||
// These are directly copied from your previous plugin
|
||||
defineRule('required', required);
|
||||
defineRule('tos', required); // Your custom rule using the 'required' function
|
||||
defineRule('min', min);
|
||||
defineRule('max', max);
|
||||
defineRule('alpha_spaces', alphaSpaces);
|
||||
defineRule('email', email);
|
||||
defineRule('min_value', minValue);
|
||||
defineRule('max_value', maxValue);
|
||||
defineRule('passwords_mismatch', confirmed); // Rule for password confirmation
|
||||
defineRule('excluded', excluded); // Rule for excluding specific values
|
||||
defineRule('membership_excluded', excluded); // Another custom rule using 'excluded'
|
||||
|
||||
// --- Configure VeeValidate ---
|
||||
// Using your custom message generator and validation triggers
|
||||
configure({
|
||||
generateMessage: (ctx) => {
|
||||
// You might want to provide more user-friendly names for fields
|
||||
// This usually involves mapping field names or passing metadata
|
||||
const field = ctx.field; // The 'name' attribute of the VeeField
|
||||
|
||||
// Your custom messages (make sure this object is complete)
|
||||
const messages: Record<string, string> = {
|
||||
// Examples from your previous code:
|
||||
required: `[${field}] 항목은 필수 입력 값입니다.`, // Using Korean based on context
|
||||
min: `[${field}] 항목은 최소 길이를 만족해야 합니다.`, // Example for min length
|
||||
max: `[${field}] 항목은 최대 길이를 초과할 수 없습니다.`, // Example for max length
|
||||
email: `[${field}] 항목은 유효한 이메일 주소여야 합니다.`, // Example for email
|
||||
passwords_mismatch: `비밀번호가 일치하지 않습니다.`, // Specific message, field name might not be needed
|
||||
// Add other messages for alpha_spaces, min_value, max_value, excluded, etc.
|
||||
};
|
||||
|
||||
// Lookup the message, fallback to a generic one
|
||||
const message =
|
||||
messages[ctx.rule?.name || ''] ||
|
||||
`[${field}] 항목이 유효하지 않습니다.`; // Generic fallback
|
||||
return message;
|
||||
},
|
||||
// Your chosen validation triggers:
|
||||
validateOnBlur: true, // Validate when the field loses focus
|
||||
validateOnChange: true, // Validate when the value changes (and field is blurred/submitted)
|
||||
validateOnInput: false, // Do NOT validate on every keystroke
|
||||
validateOnModelUpdate: true, // Validate when v-model updates (often overlaps with other triggers)
|
||||
});
|
||||
});
|
||||
13
bobu/app/stores/faqStore.js
Normal file
13
bobu/app/stores/faqStore.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// faqStore.js
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useFaqStore = defineStore('faq', {
|
||||
state: () => ({
|
||||
faqs: []
|
||||
}),
|
||||
actions: {
|
||||
addFaq(faq) {
|
||||
this.faqs.push(faq)
|
||||
}
|
||||
}
|
||||
})
|
||||
23
bobu/app/stores/headerStateStore.ts
Normal file
23
bobu/app/stores/headerStateStore.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export type LayoutName = 'default' | 'cms-upload-header';
|
||||
|
||||
interface HeaderState {
|
||||
activeLayout: LayoutName;
|
||||
}
|
||||
|
||||
export const useHeaderStateStore = defineStore('headerState', {
|
||||
state: (): HeaderState => ({
|
||||
activeLayout: 'default', // The application starts with the default layout
|
||||
}),
|
||||
|
||||
actions: {
|
||||
setActiveLayout(layoutName: LayoutName) {
|
||||
this.activeLayout = layoutName;
|
||||
},
|
||||
},
|
||||
|
||||
getters: {
|
||||
currentLayout: (state): LayoutName => state.activeLayout,
|
||||
},
|
||||
});
|
||||
22
bobu/app/stores/modal.ts
Normal file
22
bobu/app/stores/modal.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
// Define the type for the state
|
||||
interface ModalState {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const useModalStore = defineStore('modal', {
|
||||
state: (): ModalState => ({
|
||||
isOpen: false,
|
||||
}),
|
||||
getters: {
|
||||
hiddenClass(state): string {
|
||||
return !state.isOpen ? 'hidden' : '';
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
toggleModal() {
|
||||
this.isOpen = !this.isOpen
|
||||
},
|
||||
},
|
||||
});
|
||||
50
bobu/app/stores/useCmsStore.ts
Normal file
50
bobu/app/stores/useCmsStore.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// stores/cms.ts
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export interface Action {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const useCmsStore = defineStore('cms', {
|
||||
state: () => ({
|
||||
// sidebar
|
||||
sidebarOpen: false as boolean,
|
||||
|
||||
// upload actions
|
||||
actions: [] as Action[],
|
||||
mainActionId: '' as string,
|
||||
isProcessing: false as boolean,
|
||||
currentActionId: null as string | null,
|
||||
}),
|
||||
getters: {
|
||||
mainAction(state) {
|
||||
return state.actions.find((a) => a.id === state.mainActionId)!;
|
||||
},
|
||||
dropdownActions(state) {
|
||||
return state.actions.filter((a) => a.id !== state.mainActionId);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
toggleSidebar() {
|
||||
this.sidebarOpen = !this.sidebarOpen;
|
||||
},
|
||||
setActions(payload: { actions: Action[]; mainId: string }) {
|
||||
this.actions = payload.actions;
|
||||
this.mainActionId = payload.mainId;
|
||||
},
|
||||
async performAction(id: string) {
|
||||
if (this.isProcessing) return;
|
||||
this.isProcessing = true;
|
||||
this.currentActionId = id;
|
||||
|
||||
// ▶︎ call your API or form submit here
|
||||
try {
|
||||
console.log('performance Called'); // you could show a toast, etc.
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
this.currentActionId = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
384
bobu/app/stores/user.ts
Normal file
384
bobu/app/stores/user.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import type { getAuth, User } from 'firebase/auth';
|
||||
import {
|
||||
serverTimestamp,
|
||||
doc,
|
||||
getDoc,
|
||||
setDoc,
|
||||
onSnapshot,
|
||||
} from 'firebase/firestore';
|
||||
import { createSession, logoutSession } from '@/utils/api/authFromFunction';
|
||||
import {
|
||||
createUserWithEmailAndPassword,
|
||||
updateProfile,
|
||||
signInWithEmailAndPassword,
|
||||
signOut as firebaseSignOut,
|
||||
} from 'firebase/auth';
|
||||
|
||||
let auth: ReturnType<typeof getAuth> | null = null;
|
||||
let usersCollection: any = null;
|
||||
let firebaseBase: string = '';
|
||||
|
||||
type UnsubscribeFn = (() => void) | null;
|
||||
|
||||
interface State {
|
||||
userLoggedIn: boolean;
|
||||
email: string;
|
||||
userRole: number;
|
||||
docId: string;
|
||||
isActive: boolean;
|
||||
name: string;
|
||||
profile_img: string;
|
||||
unsubscribe: UnsubscribeFn;
|
||||
}
|
||||
|
||||
interface RegisterValues {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
membership: string;
|
||||
isActive: boolean;
|
||||
profile_img: string;
|
||||
created: any;
|
||||
uid: string;
|
||||
phone: number;
|
||||
}
|
||||
|
||||
interface AuthenticateValues {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
//delaying
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: (): State => ({
|
||||
userLoggedIn: false,
|
||||
email: '',
|
||||
docId: '',
|
||||
userRole: 0,
|
||||
isActive: false,
|
||||
profile_img: '',
|
||||
name: '',
|
||||
unsubscribe: null,
|
||||
}),
|
||||
actions: {
|
||||
init() {
|
||||
const { $firebase } = useNuxtApp();
|
||||
const auth = $firebase.auth;
|
||||
let everLoggedIn = false;
|
||||
auth.onIdTokenChanged(async (user: User | null) => {
|
||||
if (user) {
|
||||
everLoggedIn = true;
|
||||
const idToken = await user.getIdToken();
|
||||
await createSession(idToken);
|
||||
this.userLoggedIn = true;
|
||||
this.initializeListener();
|
||||
} else if (everLoggedIn) {
|
||||
// only really sign out if we *were* logged in
|
||||
await logoutSession();
|
||||
this._resetStore();
|
||||
}
|
||||
});
|
||||
},
|
||||
_resetStore() {
|
||||
this.userLoggedIn = false;
|
||||
this.email = '';
|
||||
this.docId = '';
|
||||
this.userRole = 0;
|
||||
this.isActive = false;
|
||||
this.name = '';
|
||||
this.profile_img = '';
|
||||
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
},
|
||||
|
||||
initializeListener() {
|
||||
const { $firebase } = useNuxtApp();
|
||||
auth = $firebase.auth;
|
||||
usersCollection = $firebase.usersCollection;
|
||||
|
||||
if (!auth || !usersCollection) {
|
||||
throw new Error('Firebase not initialized');
|
||||
}
|
||||
|
||||
const user = auth.currentUser;
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
}
|
||||
if (user) {
|
||||
const userDocRef = doc(usersCollection, user.uid);
|
||||
this.unsubscribe = onSnapshot(userDocRef, (docSnapshot) => {
|
||||
const userData = docSnapshot.data();
|
||||
if (userData) {
|
||||
this.$patch({
|
||||
userRole: userData.role,
|
||||
isActive: userData.isActive,
|
||||
name: userData.name,
|
||||
profile_img: userData.thumbnail_url,
|
||||
docId: userData.docId,
|
||||
email: userData.email,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
async fetchUserInfo() {
|
||||
if (!auth || !usersCollection)
|
||||
throw new Error('Firebase not initialized');
|
||||
|
||||
const currentUser = auth.currentUser;
|
||||
if (!currentUser) return null;
|
||||
|
||||
const userDocRef = doc(usersCollection, currentUser.uid);
|
||||
const userSnap = await getDoc(userDocRef);
|
||||
|
||||
if (userSnap.exists()) {
|
||||
this.docId = userSnap.id;
|
||||
return this.docId;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
async register(values: RegisterValues) {
|
||||
if (!auth || !usersCollection)
|
||||
throw new Error('Firebase not initialized');
|
||||
|
||||
const userCred = await createUserWithEmailAndPassword(
|
||||
auth,
|
||||
values.email,
|
||||
values.password
|
||||
);
|
||||
|
||||
if (!userCred.user) {
|
||||
throw new Error('User creation failed.');
|
||||
}
|
||||
|
||||
const userId = userCred.user.uid;
|
||||
|
||||
// Use setDoc + doc() with serverTimestamp
|
||||
await setDoc(doc(usersCollection, userId), {
|
||||
docId: userId,
|
||||
name: values.name,
|
||||
email: values.email,
|
||||
membership: values.membership,
|
||||
role: 1,
|
||||
isActive: true,
|
||||
profile_img: '',
|
||||
created: serverTimestamp(),
|
||||
});
|
||||
|
||||
await updateProfile(userCred.user, {
|
||||
displayName: values.name,
|
||||
});
|
||||
|
||||
this.$patch({ userLoggedIn: true });
|
||||
},
|
||||
// register for admin
|
||||
|
||||
async registerFromAdmin(values: RegisterValues) {
|
||||
if (!auth || !usersCollection)
|
||||
throw new Error('Firebase not initialized');
|
||||
|
||||
try {
|
||||
// 1. Get the ID token of the currently logged-in admin user
|
||||
const idToken = await auth.currentUser?.getIdToken();
|
||||
|
||||
if (!idToken) {
|
||||
throw new Error(
|
||||
'Authentication token not found. Ensure the user is logged in.'
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Send a request to the Cloud Function
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${idToken}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
};
|
||||
const REGISTER_NEW_USER_URL = `${firebaseBase}/registerNewUser`;
|
||||
|
||||
const response = await fetch(REGISTER_NEW_USER_URL, requestOptions);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Registration failed.');
|
||||
}
|
||||
|
||||
// Use a notification or other UI method to inform the admin
|
||||
// toast.success(`New user registered with UID: ${data.uid}`);
|
||||
console.log('New user registered with UID:', data.uid);
|
||||
} catch (error) {
|
||||
// Use a notification or other UI method to display the error
|
||||
// toast.error(error.message);
|
||||
console.error('Error registering user:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async registerBatchFromAdmin(users: Array<RegisterValues>) {
|
||||
if (!auth || !usersCollection)
|
||||
throw new Error('Firebase not initialized');
|
||||
|
||||
const results = [];
|
||||
|
||||
// 1. Get the ID token of the currently logged-in admin user
|
||||
const idToken = await auth.currentUser?.getIdToken();
|
||||
|
||||
if (!idToken) {
|
||||
throw new Error(
|
||||
'Authentication token not found. Ensure the user is logged in.'
|
||||
);
|
||||
}
|
||||
|
||||
// Iterate over each user and attempt to register
|
||||
for (let values of users) {
|
||||
console.log('values', values);
|
||||
|
||||
try {
|
||||
// 2. Send a request to the Cloud Function for each user
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${idToken}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
};
|
||||
const REGISTER_NEW_USER_URL = `${firebaseBase}/registerNewUser`;
|
||||
|
||||
const response = await fetch(REGISTER_NEW_USER_URL, requestOptions);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Registration failed.');
|
||||
}
|
||||
|
||||
// Log or notify success for this user
|
||||
console.log('New user registered with UID:', data.uid);
|
||||
results.push({ success: true, email: values.email, uid: data.uid });
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
console.error('Error registering user:', errorMessage);
|
||||
results.push({
|
||||
success: false,
|
||||
email: values.email,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// The results array will have the success status for each user
|
||||
return results;
|
||||
},
|
||||
|
||||
async visitorRegister(values: RegisterValues) {
|
||||
if (!auth || !usersCollection)
|
||||
throw new Error('Firebase not initialized');
|
||||
|
||||
try {
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
};
|
||||
const VISITOR_REGISTER_URL = `${firebaseBase}/visitorRegister`;
|
||||
const response = await fetch(VISITOR_REGISTER_URL, requestOptions);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Registration failed.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error registering user:', error);
|
||||
}
|
||||
},
|
||||
async adminChangePassword(email: string, newPassword: string) {
|
||||
if (!auth || !usersCollection)
|
||||
throw new Error('Firebase not initialized');
|
||||
|
||||
try {
|
||||
// 1. Get the ID token of the currently logged-in admin user
|
||||
const idToken = await auth.currentUser?.getIdToken();
|
||||
|
||||
if (!idToken) {
|
||||
throw new Error(
|
||||
'Authentication token not found. Ensure the user is logged in.'
|
||||
);
|
||||
}
|
||||
|
||||
// Data to send
|
||||
const payload = {
|
||||
email: email,
|
||||
newPassword: newPassword,
|
||||
};
|
||||
|
||||
// 2. Send a request to the Cloud Function
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${idToken}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
const CHANGE_PASSWORD_URL = `${firebaseBase}/adminChangePassword`;
|
||||
const response = await fetch(CHANGE_PASSWORD_URL, requestOptions);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Password change failed.');
|
||||
}
|
||||
|
||||
// Use a notification or other UI method to inform the admin
|
||||
// toast.success(`Password changed successfully for email: ${email}`);
|
||||
console.log(`Password changed successfully for email:`, email);
|
||||
} catch (error) {
|
||||
// Use a notification or other UI method to display the error
|
||||
// toast.error(error.message);
|
||||
console.error('Error changing password:', error);
|
||||
}
|
||||
},
|
||||
//Logs in a user using Firebase Authentication and initializes the listener to monitor user data changes.
|
||||
async authenticate(values: AuthenticateValues) {
|
||||
const { $firebase } = useNuxtApp();
|
||||
auth = $firebase.auth;
|
||||
usersCollection = $firebase.usersCollection;
|
||||
|
||||
if (!auth || !usersCollection)
|
||||
throw new Error('Firebase not initialized');
|
||||
await signInWithEmailAndPassword(auth, values.email, values.password);
|
||||
},
|
||||
|
||||
async signOut() {
|
||||
const { $firebase } = useNuxtApp();
|
||||
const auth = $firebase.auth;
|
||||
if (!auth) throw new Error('Firebase not initialized');
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
await firebaseSignOut(auth);
|
||||
await logoutSession();
|
||||
this._resetStore();
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
isLoggedIn: (state: State) => state.userLoggedIn,
|
||||
currentRole: (state: State) => state.userRole,
|
||||
},
|
||||
});
|
||||
|
||||
export default useUserStore;
|
||||
335
bobu/app/types/boardItem.ts
Normal file
335
bobu/app/types/boardItem.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { Timestamp, FieldValue } from '@firebase/firestore';
|
||||
import type { FunctionalComponent } from 'vue';
|
||||
import type { HTMLAttributes, VNodeProps } from 'vue';
|
||||
import type {
|
||||
CourseCategory,
|
||||
ProgramCategory,
|
||||
VideoProvider,
|
||||
} from '../data/config';
|
||||
|
||||
// Management Page - Link to firebase/functions/src/types/boardItem.ts
|
||||
// If you change , change up there too *******
|
||||
export type BoardAccessMode = 'public' | 'private' | 'admin';
|
||||
export interface CursorResponse<T> {
|
||||
items: T[];
|
||||
nextPageToken: string | null;
|
||||
}
|
||||
export interface UseBoardListOptions {
|
||||
title?: string;
|
||||
itemsPerPage?: number;
|
||||
defaultSort?: OrderByDirection;
|
||||
access?: BoardAccessMode;
|
||||
loadingMessage?: string;
|
||||
}
|
||||
//config
|
||||
|
||||
// Management Page
|
||||
export interface NavigationItem {
|
||||
name: string;
|
||||
route: any;
|
||||
icon: any; // Use a more specific type if available.
|
||||
current: boolean;
|
||||
}
|
||||
/////////////////////////Board Section ////////////////////////////
|
||||
export type boardState = {
|
||||
state: 'processing' | 'pending' | 'queued' | 'completed' | 'error';
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// Board : Elements
|
||||
|
||||
export type FileItem = {
|
||||
name: string;
|
||||
url: string;
|
||||
uuid?: string; // ? for old data structure
|
||||
};
|
||||
|
||||
export type ImageUsage = {
|
||||
boardId: string;
|
||||
type: 'ckeditor' | 'thumbnail';
|
||||
};
|
||||
export type ResizeOptions = 'w200' | 'w500' | 'w1280';
|
||||
|
||||
export type ResizedImageVersion = {
|
||||
url: string; // Publicly accessible HTTPS download URL for this WebP version.
|
||||
path: string; // Full path in Firebase Storage to this WebP version.
|
||||
width: number; // Actual width of this WebP version after resizing.
|
||||
height: number; // Actual height of this WebP version after resizing.
|
||||
format: 'webp'; // Explicitly 'webp' as all resized versions will be this format.
|
||||
label: ResizeOptions; // The label used in the filename, e.g., "200x200", "500x500".
|
||||
};
|
||||
export type ResizeStatus =
|
||||
| 'pending_resize'
|
||||
| 'resizing'
|
||||
| 'resized'
|
||||
| 'resize_error'
|
||||
| 'archived';
|
||||
export type ImageAsset = {
|
||||
uuid: string; // UUID of the image asset and as docID
|
||||
storagePath: string; // e.g., "boards/general/board123/ckeditor/uuid-abc/original/myphoto.jpg"
|
||||
originalFileNameWithExt: string; // e.g., "myphoto.jpg"
|
||||
userId: string; // ID of the user who uploaded the image.
|
||||
createdAt: Timestamp;
|
||||
boardReferences: ImageUsage[];
|
||||
originalSize?: {
|
||||
width: number;
|
||||
height: number;
|
||||
format?: string; // .jpg, .png, etc.
|
||||
};
|
||||
resizedVersions?: ResizedImageVersion[];
|
||||
lastProcessedAt?: Timestamp; // resize function last processed this image.
|
||||
status: ResizeStatus;
|
||||
processingError?: string; // Stores an error message if 'status' is 'resize_error'.
|
||||
};
|
||||
|
||||
export type ImageItem = FileItem; // Depreciated : Original Image Item
|
||||
// depreciated : old data structure
|
||||
export interface UploadFilesOptions {
|
||||
newBoardData: BoardItem;
|
||||
newThumbnail: File | null;
|
||||
newFiles: File[];
|
||||
oldThumbnailUrl?: string;
|
||||
oldFiles?: FileItem[];
|
||||
deleteThumbnail?: boolean;
|
||||
deleteFileIndexes?: number[];
|
||||
currentBoard: string; // which indices of old files to delete
|
||||
}
|
||||
// depreciated : old data structure
|
||||
export interface UploadFileData {
|
||||
input: File | null;
|
||||
preview: string;
|
||||
deleteTrigger: boolean;
|
||||
oldPreviewState: boolean;
|
||||
uploadState: boolean;
|
||||
previewState: boolean;
|
||||
displayedName: string;
|
||||
}
|
||||
// Board : Finally
|
||||
export type BoardItem = {
|
||||
//metadata
|
||||
docId: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
created: Timestamp | string;
|
||||
boardState: boardState;
|
||||
//contents
|
||||
description: string;
|
||||
announcement?: boolean;
|
||||
//files
|
||||
files?: FileItem[];
|
||||
thumbnailUuids?: string; //New field for thumbnailAssetUuid
|
||||
ckAssetUuids?: string[]; // Stores an array of imageAssetUuids used in description
|
||||
//depricated
|
||||
boards_number?: number; //depriciated
|
||||
ishidden?: boolean; // depriciated, we can use this for later admin control
|
||||
thumbnail?: ImageItem; //Leave it for previous data structure
|
||||
};
|
||||
// Extended Board Types
|
||||
export interface ProjectBoard extends BoardItem {
|
||||
subtitle: string;
|
||||
displayDate?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export interface QuizBoard extends BoardItem {
|
||||
question: string;
|
||||
answers: {
|
||||
text: string;
|
||||
correct: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
url: string;
|
||||
duration: number;
|
||||
provider: VideoProvider;
|
||||
vimeoId?: string;
|
||||
}
|
||||
export interface VideoBoard extends BoardItem {
|
||||
video: VideoInfo;
|
||||
}
|
||||
export interface CourseBoard extends BoardItem {
|
||||
category: CourseCategory;
|
||||
isStrict: boolean;
|
||||
courseElements?: CourseElement[];
|
||||
headline?: string;
|
||||
video?: VideoInfo;
|
||||
expiresWhen?: string;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
// Board : Types Elements
|
||||
export interface CourseElement {
|
||||
docId: string;
|
||||
order: number;
|
||||
title: string;
|
||||
type: CourseElementType;
|
||||
duration?: number;
|
||||
}
|
||||
export interface CategoryItem {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
export type CourseElementType = 'video' | 'quiz' | 'docs';
|
||||
|
||||
//Program
|
||||
export interface CourseInProgram {
|
||||
docId: string;
|
||||
title: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface ProgramBoard extends BoardItem {
|
||||
courses: CourseInProgram[];
|
||||
category: ProgramCategory;
|
||||
isStrict: boolean;
|
||||
headline?: string;
|
||||
video?: VideoInfo;
|
||||
expiresWhen?: string;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
//past
|
||||
|
||||
export interface ProgramWithProgress extends ProgramBoard {
|
||||
progressPercentage: number;
|
||||
completed: boolean;
|
||||
lastCourseProgressDocId: string | null;
|
||||
}
|
||||
|
||||
//Course
|
||||
export interface Coursecreators {
|
||||
id: number;
|
||||
name: string;
|
||||
route: { name: string };
|
||||
initial: string;
|
||||
current: boolean;
|
||||
}
|
||||
export type CourseNavItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
route: { name: string };
|
||||
current: boolean;
|
||||
docId: string;
|
||||
lessonFinish: boolean;
|
||||
type: string;
|
||||
progressId: string;
|
||||
courseId: string;
|
||||
};
|
||||
|
||||
export interface Lesson {
|
||||
docId: string;
|
||||
order: number;
|
||||
title: string;
|
||||
type: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
//Progress
|
||||
export type Progress = {
|
||||
docId: string;
|
||||
boards_number: number;
|
||||
courseCategory: string;
|
||||
courseId: string;
|
||||
created: Timestamp;
|
||||
lessons: LessonProgress[]; // ✅ Fixed here
|
||||
userId: string;
|
||||
courseFinish: Timestamp;
|
||||
courseTitle: string;
|
||||
userSurveyed?: boolean;
|
||||
ishidden?: boolean;
|
||||
finishedDate?: Timestamp;
|
||||
complete?: boolean;
|
||||
extended?: boolean;
|
||||
};
|
||||
export type LessonProgress = {
|
||||
currentTime?: number;
|
||||
docId: string;
|
||||
duration?: number;
|
||||
lessonFinish: boolean;
|
||||
order: number;
|
||||
type: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export interface ProgressWithRuntimeData
|
||||
extends Omit<Progress, 'courseTitle' | 'courseCategory' | 'percentage'> {
|
||||
totalDuration?: number;
|
||||
currentTime?: number;
|
||||
percentage?: number;
|
||||
courseTitle: string;
|
||||
category?: string;
|
||||
courseThumbnail?: string;
|
||||
courseHeadline?: string;
|
||||
courseCategory: string;
|
||||
}
|
||||
|
||||
export interface UserNavigation {
|
||||
name: string;
|
||||
route: { name: string };
|
||||
onClick?: () => void; // Add an optional onClick property for handling the click event
|
||||
}
|
||||
export interface CreateProgressResponse {
|
||||
docId: string;
|
||||
}
|
||||
|
||||
export interface CertificateResponse {
|
||||
status: 'success' | 'failed';
|
||||
message?: {
|
||||
imageUrl: string;
|
||||
pdfUrl: string;
|
||||
uniqueId: string;
|
||||
};
|
||||
}
|
||||
|
||||
//Nav
|
||||
export type SortOption = {
|
||||
sortId: number;
|
||||
name: string;
|
||||
rule: OrderByDirection;
|
||||
};
|
||||
|
||||
export type OrderByDirection = 'asc' | 'desc';
|
||||
|
||||
//user
|
||||
export type UserRegistrationValues = {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
membership:
|
||||
| '당사자'
|
||||
| '동료지원가'
|
||||
| '당사자 지인 또는 가족'
|
||||
| '기관종사자'
|
||||
| '일반시민';
|
||||
tos: boolean;
|
||||
};
|
||||
export interface LoginValues {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface VimeoPlaybackData {
|
||||
duration: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
//Tests
|
||||
export interface CompData {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
name: string;
|
||||
description: string;
|
||||
href: string;
|
||||
icon: FunctionalComponent<HTMLAttributes & VNodeProps, any>;
|
||||
}
|
||||
|
||||
export interface CallToAction {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: FunctionalComponent<HTMLAttributes & VNodeProps, any>;
|
||||
}
|
||||
//Funtion Types
|
||||
43
bobu/app/types/firebaseTypes.ts
Normal file
43
bobu/app/types/firebaseTypes.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
//FIrebase
|
||||
import type { FirebaseApp } from 'firebase/app';
|
||||
import type { Auth, RecaptchaVerifier } from 'firebase/auth';
|
||||
import type { Firestore, CollectionReference } from 'firebase/firestore';
|
||||
import type { FirebaseStorage } from 'firebase/storage';
|
||||
import type { Functions } from 'firebase/functions';
|
||||
import type { Analytics } from 'firebase/analytics';
|
||||
|
||||
export interface FirebasePlugin {
|
||||
firebaseApp: FirebaseApp;
|
||||
auth: Auth;
|
||||
db: Firestore;
|
||||
storage: FirebaseStorage;
|
||||
functions: Functions;
|
||||
analytics: Analytics;
|
||||
usersCollection: CollectionReference;
|
||||
faqboardsCollection: CollectionReference;
|
||||
countersCollection: CollectionReference;
|
||||
attendsCollection: CollectionReference;
|
||||
noticesCollection: CollectionReference;
|
||||
videosCollection: CollectionReference;
|
||||
quizzesCollection: CollectionReference;
|
||||
coursesCollection: CollectionReference;
|
||||
progressesCollection: CollectionReference;
|
||||
usergroupsCollection: CollectionReference;
|
||||
surveysCollection: CollectionReference;
|
||||
questionsCollection: CollectionReference;
|
||||
programsCollection: CollectionReference;
|
||||
headersCollection: CollectionReference;
|
||||
certificatesCollection: CollectionReference;
|
||||
projectsCollection: CollectionReference;
|
||||
signInWithGoogle: () => Promise<any>;
|
||||
createRecaptchaVerifier: (elementId: string) => RecaptchaVerifier;
|
||||
}
|
||||
|
||||
export interface FirebaseConfig {
|
||||
apiKey: string;
|
||||
authDomain: string;
|
||||
projectId: string;
|
||||
storageBucket: string;
|
||||
messagingSenderId: string;
|
||||
appId: string;
|
||||
}
|
||||
3
bobu/app/types/index.ts
Normal file
3
bobu/app/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './boardItem';
|
||||
export * from './firebaseTypes';
|
||||
export * from './vueRefs';
|
||||
7
bobu/app/types/naver.d.ts
vendored
Normal file
7
bobu/app/types/naver.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export {}; // make this file a module
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
naver: any; // or more specific types if you’d like
|
||||
}
|
||||
}
|
||||
9
bobu/app/types/nuxt.d.ts
vendored
Normal file
9
bobu/app/types/nuxt.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { FirebasePlugin } from '~/plugins/00_firebase.client';
|
||||
|
||||
declare module '#app' {
|
||||
interface NuxtApp {
|
||||
$firebase: FirebasePlugin;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
39
bobu/app/types/vueRefs.ts
Normal file
39
bobu/app/types/vueRefs.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
BoardItem,
|
||||
UploadFileData,
|
||||
BoardAccessMode,
|
||||
OrderByDirection,
|
||||
} from '~/types';
|
||||
|
||||
export interface ThumbnailData {
|
||||
input: Ref<File | null>;
|
||||
preview: Ref<string>;
|
||||
deleteTrigger: Ref<boolean>;
|
||||
oldPreviewState: Ref<boolean>;
|
||||
uploadState: Ref<boolean>;
|
||||
previewState: Ref<boolean>;
|
||||
displayedName: Ref<string>;
|
||||
}
|
||||
|
||||
export type UpdateDisplayedItemsFn = (
|
||||
currentItems: Ref<BoardItem[]>,
|
||||
displayedItems: Ref<BoardItem[]>,
|
||||
currentPage: Ref<number>,
|
||||
itemsPerPage: number
|
||||
) => void;
|
||||
|
||||
export interface UploadsData {
|
||||
thumbnail: ThumbnailData;
|
||||
files: UploadFileData[];
|
||||
}
|
||||
//For BoardNav
|
||||
export interface BoardNavConfig {
|
||||
currentPage: Ref<number>;
|
||||
currentCollection: string;
|
||||
sortingOrder: Ref<OrderByDirection>;
|
||||
userRole: Ref<number>;
|
||||
currentItems: Ref<BoardItem[]>;
|
||||
itemsPerPage: number;
|
||||
access?: BoardAccessMode;
|
||||
totalCount?: Ref<number>;
|
||||
}
|
||||
24
bobu/app/utils/api/authFromFunction.ts
Normal file
24
bobu/app/utils/api/authFromFunction.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const SESSION_URL = 'https://createsession-d4sni42fjq-du.a.run.app';
|
||||
const LOGOUT_URL = 'https://logout-d4sni42fjq-du.a.run.app';
|
||||
|
||||
/**
|
||||
* Call Firebase Function to create session cookie.
|
||||
* Stores session in secure HTTP-only cookie via server.
|
||||
*/
|
||||
export async function createSession(idToken: string): Promise<void> {
|
||||
await $fetch(SESSION_URL, {
|
||||
method: 'POST',
|
||||
body: { idToken },
|
||||
credentials: 'include', // required to receive cookie
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Firebase Function to clear session cookie.
|
||||
*/
|
||||
export async function logoutSession(): Promise<void> {
|
||||
await $fetch(LOGOUT_URL, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
}
|
||||
14
bobu/app/utils/api/countBoards.ts
Normal file
14
bobu/app/utils/api/countBoards.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { BoardAccessMode } from '~/types';
|
||||
|
||||
const FUNCTION_BASE = 'https://countboards-d4sni42fjq-du.a.run.app';
|
||||
|
||||
export async function fetchCountsFromFunction(
|
||||
collection: string,
|
||||
access: BoardAccessMode = 'public'
|
||||
): Promise<number> {
|
||||
return await $fetch<{ count: number }>(`${FUNCTION_BASE}/countBoards`, {
|
||||
method: 'POST', // ✅ explicitly typed string
|
||||
body: { collection, access }, // ✅ valid JSON object
|
||||
credentials: access !== 'public' ? 'include' : undefined, // ✅ conditional credentials
|
||||
}).then((res) => res.count || 0);
|
||||
}
|
||||
47
bobu/app/utils/api/fetchBoardsFromFunction.ts
Normal file
47
bobu/app/utils/api/fetchBoardsFromFunction.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useRuntimeConfig } from '#imports';
|
||||
import type {
|
||||
BoardItem,
|
||||
BoardAccessMode,
|
||||
CursorResponse,
|
||||
UseBoardListOptions,
|
||||
} from '~/types';
|
||||
|
||||
/* ---------- params accepted by this helper --------------------- */
|
||||
interface FetchBoardsParams {
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
pageNumber?: number;
|
||||
itemsPerPage?: number;
|
||||
access?: BoardAccessMode;
|
||||
pageToken?: string;
|
||||
}
|
||||
|
||||
const FUNCTION_BASE = 'https://fetchboards-d4sni42fjq-du.a.run.app';
|
||||
|
||||
/* --------------------------------------------------------------- */
|
||||
export async function fetchBoardsFromFunction<T extends BoardItem>(
|
||||
collection: string,
|
||||
{
|
||||
sortOrder = 'desc',
|
||||
pageNumber = 1,
|
||||
itemsPerPage = 10,
|
||||
access = 'public',
|
||||
pageToken,
|
||||
}: FetchBoardsParams = {}
|
||||
): Promise<CursorResponse<T>> {
|
||||
const isCursorMode = !!pageToken || pageNumber === undefined;
|
||||
const endpoint = isCursorMode ? 'fetchBoardsCursor' : 'fetchBoards';
|
||||
|
||||
const params: Record<string, any> = {
|
||||
collection,
|
||||
sortOrder,
|
||||
itemsPerPage,
|
||||
access,
|
||||
...(isCursorMode ? { pageToken } : { pageNumber }),
|
||||
};
|
||||
|
||||
return await $fetch<CursorResponse<T>>(`${FUNCTION_BASE}/${endpoint}`, {
|
||||
method: 'GET',
|
||||
params,
|
||||
credentials: access !== 'public' ? 'include' : undefined,
|
||||
});
|
||||
}
|
||||
13
bobu/app/utils/api/verifyFromFunction.ts
Normal file
13
bobu/app/utils/api/verifyFromFunction.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const VERIFY_URL = 'https://verifysession-d4sni42fjq-du.a.run.app';
|
||||
|
||||
export type VerifiedSession = {
|
||||
uid: string;
|
||||
email: string;
|
||||
role: number;
|
||||
};
|
||||
export async function verifySession(): Promise<VerifiedSession> {
|
||||
return await $fetch<VerifiedSession>(VERIFY_URL, {
|
||||
method: 'GET',
|
||||
credentials: 'include', // ensures session cookie is sent
|
||||
});
|
||||
}
|
||||
152
bobu/app/utils/boardUtils/FirebaseUploadAdapter.ts
Normal file
152
bobu/app/utils/boardUtils/FirebaseUploadAdapter.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// src/ckeditor/FirebaseUploadAdapter.ts
|
||||
import {
|
||||
getStorage,
|
||||
ref,
|
||||
uploadBytesResumable,
|
||||
getDownloadURL,
|
||||
type StorageReference,
|
||||
type UploadTask,
|
||||
type FirebaseStorage,
|
||||
} from 'firebase/storage';
|
||||
import type { FirebaseError } from 'firebase/app'; // Import FirebaseError type
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Import CKEditor types (optional, use any if types are problematic)
|
||||
import type { Editor } from '@ckeditor/ckeditor5-core';
|
||||
import type { FileLoader } from '@ckeditor/ckeditor5-upload/src/filerepository'; // More specific type
|
||||
|
||||
class FirebaseUploadAdapter {
|
||||
// CKEditor FileLoader instance
|
||||
private loader: FileLoader; // Or MinimalFileLoader or any
|
||||
// Firebase Storage instance
|
||||
private storage: FirebaseStorage;
|
||||
// Firebase Storage reference for the current upload
|
||||
private storageRef?: StorageReference;
|
||||
// Firebase Upload task for the current upload
|
||||
private uploadTask?: UploadTask;
|
||||
|
||||
constructor(loader: FileLoader) {
|
||||
// Or MinimalFileLoader or any
|
||||
this.loader = loader;
|
||||
this.storage = getStorage(); // Get Firebase storage instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the upload process.
|
||||
* Returns a Promise that resolves with the uploaded image's data object
|
||||
* required by CKEditor ({ default: 'image_url' }).
|
||||
*/
|
||||
public async upload(): Promise<{ default: string }> {
|
||||
try {
|
||||
const file = await this.loader.file;
|
||||
|
||||
if (!file) {
|
||||
throw new Error('File not found in CKEditor loader.');
|
||||
}
|
||||
|
||||
// --- 1. Generate unique filename ---
|
||||
const fileExtension = file.name.split('.').pop() || 'file'; // Handle cases with no extension
|
||||
const uniqueFileName = `${uuidv4()}.${fileExtension}`;
|
||||
const storagePath = `ckeditor_uploads/${uniqueFileName}`; // Adjust path as needed
|
||||
this.storageRef = ref(this.storage, storagePath);
|
||||
|
||||
// --- 2. Create the upload task ---
|
||||
this.uploadTask = uploadBytesResumable(this.storageRef, file);
|
||||
|
||||
// --- 3. Return a promise that resolves on success/rejects on error ---
|
||||
return new Promise((resolve, reject) => {
|
||||
this.uploadTask?.on(
|
||||
'state_changed',
|
||||
(snapshot) => {
|
||||
// Progress monitoring (optional)
|
||||
const progress =
|
||||
(snapshot.bytesTransferred / snapshot.totalBytes) * 100;
|
||||
console.log(`Upload is ${progress}% done`);
|
||||
// Update CKEditor loader progress (optional)
|
||||
// this.loader.uploadTotal = snapshot.totalBytes;
|
||||
// this.loader.uploaded = snapshot.bytesTransferred;
|
||||
},
|
||||
(error: unknown) => {
|
||||
// Use unknown for generic error catching
|
||||
// Handle unsuccessful uploads
|
||||
console.error('Firebase upload error:', error);
|
||||
let errorMessage = 'Upload failed due to an unknown error.';
|
||||
// Check if it's a FirebaseError for specific codes
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error
|
||||
) {
|
||||
const firebaseError = error as FirebaseError; // Type assertion
|
||||
errorMessage = `Upload failed: ${firebaseError.code} - ${firebaseError.message}`;
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = `Upload failed: ${error.message}`;
|
||||
}
|
||||
reject(errorMessage); // Reject the promise
|
||||
},
|
||||
async () => {
|
||||
// Use async here for await getDownloadURL
|
||||
// Handle successful uploads on complete
|
||||
try {
|
||||
console.log('Upload successful!');
|
||||
if (!this.uploadTask?.snapshot.ref) {
|
||||
throw new Error('Upload task snapshot reference is missing.');
|
||||
}
|
||||
const downloadURL = await getDownloadURL(
|
||||
this.uploadTask.snapshot.ref
|
||||
);
|
||||
console.log('File available at', downloadURL);
|
||||
// Resolve the promise with the file data CKEditor expects
|
||||
resolve({
|
||||
default: downloadURL,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Error getting download URL:', error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
reject(`Could not get download URL: ${message}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('Error initiating upload:', error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// Ensure the promise returned by upload() is rejected
|
||||
return Promise.reject(`Error initiating upload: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts the upload process.
|
||||
*/
|
||||
public abort(): void {
|
||||
if (this.uploadTask) {
|
||||
console.log('Aborting Firebase upload...');
|
||||
this.uploadTask.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CKEditor plugin factory function that integrates the FirebaseUploadAdapter.
|
||||
*/
|
||||
export default function FirebaseUploadAdapterPlugin(editor: Editor): void {
|
||||
// Use Editor type or any
|
||||
// Check if the FileRepository plugin is loaded
|
||||
if (!editor.plugins.has('FileRepository')) {
|
||||
console.error(
|
||||
'FileRepository plugin is required for FirebaseUploadAdapterPlugin.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Register the custom adapter factory
|
||||
(editor.plugins.get('FileRepository') as any).createUploadAdapter = (
|
||||
loader: FileLoader
|
||||
) => {
|
||||
// Match loader type
|
||||
// Configure the adapter during creation.
|
||||
return new FirebaseUploadAdapter(loader);
|
||||
};
|
||||
}
|
||||
337
bobu/app/utils/boardUtils/boardFetching.ts
Normal file
337
bobu/app/utils/boardUtils/boardFetching.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import {
|
||||
query,
|
||||
orderBy,
|
||||
getDoc,
|
||||
getDocs,
|
||||
limit,
|
||||
doc,
|
||||
where,
|
||||
} from 'firebase/firestore';
|
||||
import { ROLE_THRESHOLD } from '@/data/config';
|
||||
import { fetchBoardsFromFunction } from '@/utils/api/fetchBoardsFromFunction';
|
||||
import { collection } from 'firebase/firestore';
|
||||
|
||||
//types import
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import type {
|
||||
BoardItem,
|
||||
OrderByDirection,
|
||||
FileItem,
|
||||
UploadFileData,
|
||||
BoardAccessMode,
|
||||
} from '@/types';
|
||||
import type { UpdateDisplayedItemsFn, ThumbnailData } from '@/types/vueRefs';
|
||||
|
||||
import type {
|
||||
CollectionReference,
|
||||
DocumentData,
|
||||
Query,
|
||||
QueryConstraint,
|
||||
} from 'firebase/firestore';
|
||||
import type { Router } from 'vue-router';
|
||||
//Board Actions
|
||||
|
||||
export const syncBoardAndUploadsData = <T extends BoardItem>(
|
||||
newBoard: T,
|
||||
boardsData: Ref<T>,
|
||||
uploadsData: {
|
||||
thumbnail: ThumbnailData;
|
||||
files: UploadFileData[];
|
||||
},
|
||||
createEmptyUploadFileData: () => UploadFileData,
|
||||
resetFileSlots: (files: UploadFileData[]) => void
|
||||
) => {
|
||||
console.log('[syncBoardAndUploadsData] Syncing board and upload data:', {
|
||||
newBoard,
|
||||
boardsData: boardsData.value,
|
||||
uploadsData: {
|
||||
thumbnail: uploadsData.thumbnail,
|
||||
files: uploadsData.files,
|
||||
},
|
||||
});
|
||||
// 1. Sync boardsData (safe fallback)
|
||||
boardsData.value = {
|
||||
...boardsData.value,
|
||||
docId: newBoard.docId || '',
|
||||
userId: newBoard.userId || '',
|
||||
title: newBoard.title || '',
|
||||
boardState: newBoard.boardState || { state: 'pending' },
|
||||
description: newBoard.description || '',
|
||||
announcement: newBoard.announcement || false,
|
||||
ishidden: newBoard.ishidden || false,
|
||||
boards_number: newBoard.boards_number || 0,
|
||||
created: newBoard.created || '',
|
||||
thumbnail: {
|
||||
name: newBoard.thumbnail?.name || '',
|
||||
url: newBoard.thumbnail?.url || '',
|
||||
},
|
||||
files: newBoard.files ? [...newBoard.files] : [],
|
||||
} as T;
|
||||
|
||||
// --- 2) now auto-copy all extra keys from newBoard into boardsData.value ---
|
||||
const baseKeys = new Set<keyof BoardItem>([
|
||||
'docId',
|
||||
'userId',
|
||||
'title',
|
||||
'created',
|
||||
'boardState',
|
||||
'description',
|
||||
'announcement',
|
||||
'files',
|
||||
'boards_number',
|
||||
'ishidden',
|
||||
'thumbnail',
|
||||
'thumbnailUuids',
|
||||
'ckAssetUuids',
|
||||
]);
|
||||
|
||||
for (const key of Object.keys(newBoard) as Array<keyof T>) {
|
||||
if (!baseKeys.has(key as keyof BoardItem)) {
|
||||
// @ts-ignore: we know boardsData.value can hold these extra props
|
||||
boardsData.value[key] = newBoard[key];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync thumbnail preview (old preview display only)
|
||||
if (uploadsData.thumbnail.preview && uploadsData.thumbnail.oldPreviewState) {
|
||||
uploadsData.thumbnail.preview.value = '';
|
||||
uploadsData.thumbnail.oldPreviewState.value = !!newBoard.thumbnail?.url;
|
||||
uploadsData.thumbnail.displayedName.value = newBoard.thumbnail?.name || '';
|
||||
uploadsData.thumbnail.uploadState.value = false;
|
||||
uploadsData.thumbnail.deleteTrigger.value = false;
|
||||
}
|
||||
|
||||
// 3. Sync file slots
|
||||
resetFileSlots(uploadsData.files);
|
||||
if (newBoard.files?.length) {
|
||||
newBoard.files.forEach((fileItem: FileItem) => {
|
||||
const fileSlot = createEmptyUploadFileData();
|
||||
fileSlot.input = null;
|
||||
fileSlot.preview = fileItem.url;
|
||||
fileSlot.displayedName = fileItem.name;
|
||||
fileSlot.uploadState = false;
|
||||
fileSlot.deleteTrigger = false;
|
||||
fileSlot.oldPreviewState = true;
|
||||
fileSlot.previewState = true;
|
||||
|
||||
uploadsData.files.push(fileSlot);
|
||||
console.log('[push check] fileSlot.input before push:', fileSlot.input);
|
||||
});
|
||||
}
|
||||
console.log('[syncBoardAndUploadsData] Synced board + upload state:', {
|
||||
thumbnail: uploadsData.thumbnail.displayedName.value,
|
||||
fileCount: uploadsData.files.length,
|
||||
});
|
||||
};
|
||||
|
||||
export const loadBoardDetails = async (
|
||||
docIdValue: string,
|
||||
board: Ref<BoardItem>,
|
||||
currentCollection: CollectionReference,
|
||||
router: Router,
|
||||
currentboard: string,
|
||||
userRole: Ref<number>
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const docSnapshot = await getDoc(doc(currentCollection, docIdValue));
|
||||
|
||||
if (!docSnapshot.exists()) {
|
||||
router.push({ name: `${currentboard}list` });
|
||||
} else {
|
||||
const data = docSnapshot.data();
|
||||
board.value = {
|
||||
...(data as BoardItem),
|
||||
docId: docSnapshot.id,
|
||||
};
|
||||
|
||||
if (board.value.ishidden && userRole.value < 5) {
|
||||
router.push({ name: `${currentboard}list` });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading board details:', error);
|
||||
}
|
||||
};
|
||||
//Needs modificationm not currently used.
|
||||
export const fetchUserBoards = async (
|
||||
currentCollection: CollectionReference,
|
||||
sortingOrder: Ref<OrderByDirection>,
|
||||
userRole: Ref<number>,
|
||||
currentItems: Ref<BoardItem[]>,
|
||||
updateDisplayedItems: UpdateDisplayedItemsFn,
|
||||
displayedItems: Ref<BoardItem[]>,
|
||||
currentPage: Ref<number>,
|
||||
itemsPerPage: number
|
||||
) => {
|
||||
try {
|
||||
const q = query(
|
||||
currentCollection,
|
||||
orderBy('created', sortingOrder.value),
|
||||
limit(200)
|
||||
);
|
||||
const snapshot = await getDocs(q);
|
||||
const docs = snapshot.docs.map(
|
||||
(doc) =>
|
||||
({
|
||||
docId: doc.id,
|
||||
...doc.data(),
|
||||
} as BoardItem)
|
||||
);
|
||||
const filteredDocs = docs.filter((doc) => {
|
||||
if (doc.announcement) {
|
||||
return userRole.value >= 5 || !doc.ishidden;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
currentItems.value = filteredDocs;
|
||||
updateDisplayedItems(
|
||||
currentItems,
|
||||
displayedItems,
|
||||
currentPage,
|
||||
itemsPerPage
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching Boards:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export async function fetchBoards<T extends BoardItem>(
|
||||
currentCollection: string,
|
||||
sortingOrder: Ref<OrderByDirection>,
|
||||
userRole: Ref<number>, // currently unused but can drive access logic
|
||||
currentItems: Ref<T[]>,
|
||||
currentPage: Ref<number>,
|
||||
itemsPerPage: number,
|
||||
access: BoardAccessMode = 'public'
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Fetch typed boards from cloud function
|
||||
const boards = await fetchBoardsFromFunction<T>(currentCollection, {
|
||||
sortOrder: sortingOrder.value,
|
||||
pageNumber: currentPage.value,
|
||||
itemsPerPage,
|
||||
access,
|
||||
});
|
||||
|
||||
// Replace the ref value
|
||||
currentItems.value = boards.items;
|
||||
} catch (error) {
|
||||
console.error('Error fetching boards from cloud function:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchElements<T extends BoardItem>(
|
||||
collection: CollectionReference<DocumentData>,
|
||||
options?: {
|
||||
sortField?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
limitCount?: number;
|
||||
userId?: string;
|
||||
}
|
||||
): Promise<T[]> {
|
||||
const {
|
||||
sortField = 'created',
|
||||
sortOrder = 'desc',
|
||||
limitCount = 200,
|
||||
userId,
|
||||
} = options || {};
|
||||
|
||||
const q = buildBoardQuerySimple(collection, {
|
||||
sortField,
|
||||
sortOrder,
|
||||
limitCount,
|
||||
userId,
|
||||
});
|
||||
|
||||
const snapshot = await getDocs(q);
|
||||
|
||||
return snapshot.docs.map((doc) => {
|
||||
const data = doc.data() as Record<string, any>;
|
||||
return { ...data, docId: doc.id } as T;
|
||||
});
|
||||
}
|
||||
|
||||
//helper for fetchBoards, fetch Elemetns
|
||||
interface BuildQueryOptions {
|
||||
sortField?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
limitCount?: number;
|
||||
userId?: string;
|
||||
}
|
||||
export function buildBoardQuerySimple(
|
||||
collection: CollectionReference,
|
||||
options: BuildQueryOptions = {}
|
||||
): Query {
|
||||
const {
|
||||
sortField = 'created',
|
||||
sortOrder = 'desc',
|
||||
limitCount = 200,
|
||||
userId,
|
||||
} = options;
|
||||
|
||||
const constraints = [];
|
||||
|
||||
if (userId) {
|
||||
constraints.push(where('userId', '==', userId));
|
||||
}
|
||||
|
||||
constraints.push(orderBy(sortField, sortOrder), limit(limitCount));
|
||||
|
||||
return query(collection, ...constraints);
|
||||
}
|
||||
|
||||
export function buildBoardQuery(
|
||||
collection: CollectionReference,
|
||||
userRole: Ref<number>,
|
||||
userId: Ref<string>,
|
||||
sortingOrder: Ref<OrderByDirection>,
|
||||
limitCount: number = 200
|
||||
): Query {
|
||||
if (userRole.value >= ROLE_THRESHOLD.ADMIN) {
|
||||
// Admin can see all
|
||||
return query(
|
||||
collection,
|
||||
orderBy('announcement', 'desc'),
|
||||
orderBy('created', sortingOrder.value),
|
||||
limit(limitCount)
|
||||
);
|
||||
} else {
|
||||
// Regular user: only their documents
|
||||
return query(
|
||||
collection,
|
||||
where('userId', '==', userId.value),
|
||||
orderBy('announcement', 'desc'),
|
||||
orderBy('created', sortingOrder.value),
|
||||
limit(limitCount)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userNameCache = new Map<string, string>();
|
||||
|
||||
export async function fetchUserDisplayName(userId: string): Promise<string> {
|
||||
const { $firebase } = useNuxtApp();
|
||||
const usersCollection = $firebase.usersCollection;
|
||||
if (userNameCache.has(userId)) {
|
||||
return userNameCache.get(userId)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const userDoc = await getDoc(doc(usersCollection, userId));
|
||||
if (userDoc.exists()) {
|
||||
const userData = userDoc.data();
|
||||
const displayName = userData.name || userId;
|
||||
userNameCache.set(userId, displayName);
|
||||
return displayName;
|
||||
}
|
||||
userNameCache.set(userId, userId);
|
||||
return userId;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user display name:', error);
|
||||
userNameCache.set(userId, userId);
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
263
bobu/app/utils/boardUtils/boardsActions.ts
Normal file
263
bobu/app/utils/boardUtils/boardsActions.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { ref as storageRef, deleteObject } from 'firebase/storage';
|
||||
import { deleteUserFromAuthentication } from '@/utils/firebaseUtils';
|
||||
|
||||
//types import
|
||||
import {
|
||||
query,
|
||||
where,
|
||||
getDocs,
|
||||
deleteDoc,
|
||||
doc,
|
||||
updateDoc,
|
||||
} from 'firebase/firestore';
|
||||
//types import
|
||||
import type { Ref } from 'vue';
|
||||
import type { FirebaseStorage } from 'firebase/storage';
|
||||
|
||||
import type { BoardItem } from '@/types';
|
||||
import type { CollectionReference } from 'firebase/firestore';
|
||||
|
||||
//Selecting
|
||||
export const toggleSelect = (
|
||||
selectedItems: Ref<BoardItem[]>,
|
||||
item: BoardItem
|
||||
) => {
|
||||
const index = selectedItems.value.indexOf(item);
|
||||
if (index === -1) {
|
||||
selectedItems.value.push(item);
|
||||
} else {
|
||||
selectedItems.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
export const setActiveForSelectedUsers = async (
|
||||
selectedItems: Ref<BoardItem[]>,
|
||||
showSelectBoxes: Ref<boolean>,
|
||||
usersCollection: CollectionReference
|
||||
) => {
|
||||
if (selectedItems.value.length === 0) {
|
||||
window.alert('활성화할 사용자를 선택하여 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm('선택된 사용자를 활성화하시겠습니까?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set isActive field for selected users to true
|
||||
try {
|
||||
const promises = selectedItems.value.map((item) => {
|
||||
const userRef = doc(usersCollection, item.docId);
|
||||
return updateDoc(userRef, { isActive: true });
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
console.error('Error setting users to active:', error);
|
||||
alert('사용자 활성화를 실패하였습니다');
|
||||
}
|
||||
|
||||
selectedItems.value = [];
|
||||
showSelectBoxes.value = true;
|
||||
};
|
||||
|
||||
//Deleting
|
||||
function getStoragePathFromUrl(url: string): string {
|
||||
const baseUrl = 'https://firebasestorage.googleapis.com/v0/b/';
|
||||
if (!url.includes(baseUrl)) return url;
|
||||
|
||||
const [, pathWithToken] = url.split('/o/');
|
||||
if (!pathWithToken) return url;
|
||||
const [encodedPath] = pathWithToken.split('?');
|
||||
return decodeURIComponent(encodedPath ?? '');
|
||||
}
|
||||
async function deleteBoardAssets(
|
||||
board: BoardItem,
|
||||
deleteFile: (url?: string) => Promise<void>
|
||||
) {
|
||||
const fileTasks = board.files?.map((f) => deleteFile(f.url)) ?? [];
|
||||
const thumbnailTask = deleteFile(board.thumbnail?.url);
|
||||
return Promise.all([thumbnailTask, ...fileTasks]);
|
||||
}
|
||||
|
||||
function createDeleteFile(storage: FirebaseStorage) {
|
||||
return async (url?: string) => {
|
||||
if (!url) return;
|
||||
|
||||
const path = getStoragePathFromUrl(url);
|
||||
const fileRef = storageRef(storage, path);
|
||||
|
||||
try {
|
||||
await deleteObject(fileRef);
|
||||
console.log('[deleteFile] Deleted original:', path);
|
||||
} catch (err) {
|
||||
console.warn('[deleteFile] Could not delete original:', path, err);
|
||||
}
|
||||
|
||||
// Only delete resized versions if it's a thumbnail
|
||||
console.log('lookhere', path);
|
||||
if (path.includes('thumbnails/')) {
|
||||
const basePath = path.replace(/\.[^/.]+$/, '');
|
||||
const extension = 'webp';
|
||||
const sizes = ['200x200', '500x500'];
|
||||
const resizedPaths = sizes.map(
|
||||
(size) => `${basePath}_${size}.${extension}`
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
resizedPaths.map(async (resizedPath) => {
|
||||
const resizedRef = storageRef(storage, resizedPath);
|
||||
try {
|
||||
await deleteObject(resizedRef);
|
||||
console.log('[deleteFile] Deleted resized:', resizedPath);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[deleteFile] Could not delete resized:',
|
||||
resizedPath,
|
||||
err
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const deleteuserSelected = async (
|
||||
selectedItems: Ref<BoardItem[]>,
|
||||
showSelectBoxes: Ref<boolean>,
|
||||
currentCollection: CollectionReference,
|
||||
storage: FirebaseStorage
|
||||
) => {
|
||||
if (selectedItems.value.length === 0) {
|
||||
window.alert('삭제할 글을 선택하여 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm('선택된 모든 글을 삭제하시겠습니까?');
|
||||
if (!confirmed) return;
|
||||
|
||||
const deleteFile = createDeleteFile(storage);
|
||||
|
||||
try {
|
||||
// ✅ Delete files from Storage
|
||||
await Promise.all(
|
||||
selectedItems.value.map((item) => deleteBoardAssets(item, deleteFile))
|
||||
);
|
||||
} catch (error) {
|
||||
alert('첨부파일 삭제를 실패하였습니다');
|
||||
console.error(error);
|
||||
}
|
||||
const { $firebase } = useNuxtApp();
|
||||
const progressesCollection = $firebase.progressesCollection;
|
||||
try {
|
||||
// ✅ Delete progresses from Firestore
|
||||
await Promise.all(
|
||||
selectedItems.value.map(async (item) => {
|
||||
const progressRef = progressesCollection;
|
||||
const q = query(progressRef, where('userId', '==', item.docId));
|
||||
const querySnapshot = await getDocs(q);
|
||||
return Promise.all(querySnapshot.docs.map((doc) => deleteDoc(doc.ref)));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error deleting progresses:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
// ✅ Delete board documents
|
||||
await Promise.all(
|
||||
selectedItems.value.map((item) =>
|
||||
deleteDoc(doc(currentCollection, item.docId))
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error deleting Boards:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
// ✅ Delete Firebase users
|
||||
await Promise.all(
|
||||
selectedItems.value.map((item) =>
|
||||
deleteUserFromAuthentication(item.docId)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
alert('Firebase 사용자 삭제 실패');
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
selectedItems.value = [];
|
||||
showSelectBoxes.value = true;
|
||||
};
|
||||
|
||||
export const deleteSingle = async (
|
||||
board: BoardItem,
|
||||
currentCollection: CollectionReference,
|
||||
storage: FirebaseStorage
|
||||
): Promise<boolean> => {
|
||||
const confirmed = window.confirm('해당글을 정말로 삭제하시겠습니까?');
|
||||
if (!confirmed) return false;
|
||||
|
||||
const deleteFile = createDeleteFile(storage);
|
||||
|
||||
try {
|
||||
await deleteBoardAssets(board, deleteFile);
|
||||
} catch (error) {
|
||||
console.error('첨부파일 삭제 실패:', error);
|
||||
alert('첨부파일 삭제를 실패하였습니다');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteDoc(doc(currentCollection, board.docId));
|
||||
alert('글이 성공적으로 삭제되었습니다.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('문서 삭제 실패:', error);
|
||||
alert('글 삭제에 실패하였습니다.');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSelected = async (
|
||||
selectedItems: Ref<BoardItem[]>,
|
||||
showSelectBoxes: Ref<boolean>,
|
||||
currentCollection: CollectionReference,
|
||||
storage: FirebaseStorage
|
||||
) => {
|
||||
if (selectedItems.value.length === 0) {
|
||||
showSelectBoxes.value = false;
|
||||
window.alert('삭제할 글을 선택하여 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm('선택된 모든 글을 삭제하시겠습니까?');
|
||||
if (!confirmed) return;
|
||||
|
||||
const deleteFile = createDeleteFile(storage);
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
selectedItems.value.map((item) => deleteBoardAssets(item, deleteFile))
|
||||
);
|
||||
} catch (error) {
|
||||
alert('첨부파일 삭제를 실패하였습니다');
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
selectedItems.value.map((item) =>
|
||||
deleteDoc(doc(currentCollection, item.docId))
|
||||
)
|
||||
);
|
||||
selectedItems.value = [];
|
||||
} catch (error) {
|
||||
console.error('Error deleting Boards:', error);
|
||||
}
|
||||
|
||||
showSelectBoxes.value = false;
|
||||
window.location.reload();
|
||||
};
|
||||
64
bobu/app/utils/boardUtils/boardsEmpty.ts
Normal file
64
bobu/app/utils/boardUtils/boardsEmpty.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { CourseCategories } from '@/data/config';
|
||||
import type {
|
||||
BoardItem,
|
||||
VideoBoard,
|
||||
QuizBoard,
|
||||
CourseBoard,
|
||||
ProgramBoard,
|
||||
} from '@/types';
|
||||
|
||||
export function createEmptyBoardItem(): BoardItem {
|
||||
return {
|
||||
docId: '',
|
||||
userId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
boards_number: 0,
|
||||
created: '',
|
||||
files: [],
|
||||
thumbnail: { name: '', url: '' },
|
||||
boardState: {
|
||||
state: 'pending',
|
||||
error: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
export function createEmptyVideoBoard(): VideoBoard {
|
||||
return {
|
||||
...createEmptyBoardItem(),
|
||||
video: {
|
||||
url: '',
|
||||
duration: 0,
|
||||
provider: 'unknown',
|
||||
},
|
||||
};
|
||||
}
|
||||
export function createEmptyQuizBoard(): QuizBoard {
|
||||
return {
|
||||
...createEmptyBoardItem(),
|
||||
question: '',
|
||||
answers: [],
|
||||
};
|
||||
}
|
||||
export function createEmptyCourseBoard(): CourseBoard {
|
||||
return {
|
||||
...createEmptyBoardItem(),
|
||||
category: CourseCategories[0],
|
||||
isStrict: false,
|
||||
courseElements: [],
|
||||
headline: '',
|
||||
video: {
|
||||
url: '',
|
||||
duration: 0,
|
||||
provider: 'unknown',
|
||||
},
|
||||
expiresWhen: '',
|
||||
keywords: [],
|
||||
};
|
||||
}
|
||||
// export function createEmptyProgramBoard(): ProgramBoard {
|
||||
// return {
|
||||
// ...createEmptyBoardItem(),
|
||||
// courses: [],
|
||||
// };
|
||||
// }
|
||||
206
bobu/app/utils/boardUtils/boardsFileUtils.ts
Normal file
206
bobu/app/utils/boardUtils/boardsFileUtils.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { ref, reactive } from 'vue';
|
||||
import { UploadSettings } from '@/data/config';
|
||||
//types import
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
import type { UploadsData, UploadFileData, ThumbnailData } from '@/types';
|
||||
|
||||
export function createEmptyThumbnailData(): ThumbnailData {
|
||||
return {
|
||||
input: ref(null),
|
||||
preview: ref(''),
|
||||
deleteTrigger: ref(false),
|
||||
oldPreviewState: ref(false),
|
||||
uploadState: ref(false),
|
||||
previewState: ref(false),
|
||||
displayedName: ref(''),
|
||||
};
|
||||
}
|
||||
|
||||
// For regular files (reactive plain object)
|
||||
export function createEmptyUploadFileData(): UploadFileData {
|
||||
return reactive({
|
||||
input: null,
|
||||
preview: '',
|
||||
deleteTrigger: false,
|
||||
oldPreviewState: false,
|
||||
uploadState: false,
|
||||
previewState: false,
|
||||
displayedName: '',
|
||||
});
|
||||
}
|
||||
// Handle regular file input (PDF, DOC, etc.)
|
||||
export function onPlainFileChange(event: Event, fileData: UploadFileData) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
fileData.input = file;
|
||||
fileData.uploadState = true;
|
||||
fileData.displayedName = file.name;
|
||||
fileData.preview = URL.createObjectURL(file);
|
||||
fileData.previewState = true;
|
||||
|
||||
console.log('[onPlainFileChange] Uploaded:', file.name);
|
||||
}
|
||||
|
||||
// Handle thumbnail file input (image only)
|
||||
export function onThumbnailChange(event: Event, fileData: ThumbnailData) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) {
|
||||
console.warn('[onThumbnailChange] No file selected');
|
||||
return;
|
||||
}
|
||||
|
||||
fileData.input.value = file;
|
||||
fileData.uploadState!.value = true;
|
||||
fileData.displayedName!.value = file.name;
|
||||
fileData.preview.value = URL.createObjectURL(file);
|
||||
fileData.previewState.value = true;
|
||||
fileData.oldPreviewState.value = false;
|
||||
|
||||
console.log('[onThumbnailChange] Thumbnail selected:', {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
});
|
||||
}
|
||||
|
||||
// Add a new file slot to the array
|
||||
export function addFileSlot(files: UploadFileData[]) {
|
||||
console.log('[addFileSlot] Adding new file input slot');
|
||||
files.push(createEmptyUploadFileData());
|
||||
}
|
||||
// Remove a file slot at a given index
|
||||
export function removeFileSlot(files: UploadFileData[], index: number) {
|
||||
if (index >= 0 && index < files.length) {
|
||||
console.log(`[removeFileSlot] Removing file slot at index ${index}`);
|
||||
files.splice(index, 1);
|
||||
} else {
|
||||
console.warn(`[removeFileSlot] Invalid index: ${index}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all file slots
|
||||
export function resetFileSlots(files: UploadFileData[]) {
|
||||
console.log('[resetFileSlots] Resetting all file slots');
|
||||
files = [];
|
||||
}
|
||||
|
||||
// Get current valid files from uploadsData
|
||||
export const getFilesFromUploads = (uploadsData: UploadsData) => {
|
||||
const rawFiles = uploadsData.files || [];
|
||||
|
||||
console.log('[getFilesFromUploads] Raw fileData count:', rawFiles.length);
|
||||
|
||||
const validFiles = rawFiles
|
||||
.map((fileData, i) => {
|
||||
const file = fileData?.input;
|
||||
if (!(file instanceof File)) {
|
||||
console.warn(
|
||||
`[getFilesFromUploads] Skipped non-File at index ${i}`,
|
||||
file
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const isValid = UploadSettings.isValidFile(file);
|
||||
console.log(`[getFilesFromUploads] File at index ${i}:`, {
|
||||
name: file.name,
|
||||
valid: isValid,
|
||||
});
|
||||
|
||||
return isValid ? file : null;
|
||||
})
|
||||
.filter((file): file is File => file !== null);
|
||||
|
||||
const rawThumb = uploadsData.thumbnail.input.value;
|
||||
const thumbnail =
|
||||
rawThumb instanceof File && UploadSettings.isImageFile(rawThumb)
|
||||
? rawThumb
|
||||
: null;
|
||||
|
||||
if (thumbnail) {
|
||||
console.log('[getFilesFromUploads] Thumbnail is valid:', thumbnail.name);
|
||||
} else {
|
||||
console.log('[getFilesFromUploads] No valid thumbnail selected');
|
||||
}
|
||||
|
||||
return {
|
||||
thumbnail,
|
||||
files: validFiles,
|
||||
};
|
||||
};
|
||||
|
||||
// Identify which file indexes are marked for deletion
|
||||
export const getDeleteFileIndexesFromUploads = (
|
||||
uploadsData: UploadsData
|
||||
): number[] => {
|
||||
const indexes = uploadsData.files
|
||||
.map((fileData, i) => (fileData.deleteTrigger ? i : -1))
|
||||
.filter((i) => i !== -1);
|
||||
|
||||
console.log(`[getDeleteFileIndexesFromUploads] Indexes to delete:`, indexes);
|
||||
return indexes;
|
||||
};
|
||||
|
||||
// Reset a file input and clear optional external state
|
||||
|
||||
export const clearThumbnailInput = (
|
||||
fileData: ThumbnailData,
|
||||
inputElementOrId?: string | HTMLInputElement | null,
|
||||
clearExternalState?: () => void
|
||||
) => {
|
||||
const fileName = fileData.displayedName?.value ?? '(unnamed thumbnail)';
|
||||
console.log(`[clearThumbnailInput] Clearing thumbnail input: ${fileName}`);
|
||||
|
||||
fileData.deleteTrigger.value = true;
|
||||
fileData.uploadState.value = false;
|
||||
fileData.previewState.value = false;
|
||||
fileData.oldPreviewState.value = false;
|
||||
fileData.preview.value = '';
|
||||
fileData.displayedName.value = '';
|
||||
fileData.input.value = null;
|
||||
|
||||
if (clearExternalState) {
|
||||
clearExternalState();
|
||||
}
|
||||
|
||||
if (typeof inputElementOrId === 'string') {
|
||||
const el = document.getElementById(inputElementOrId) as HTMLInputElement;
|
||||
if (el) el.value = '';
|
||||
} else if (inputElementOrId instanceof HTMLInputElement) {
|
||||
inputElementOrId.value = '';
|
||||
}
|
||||
|
||||
console.log('[clearThumbnailInput] Thumbnail input cleared');
|
||||
};
|
||||
|
||||
export const clearFileInput = (
|
||||
fileData: UploadFileData,
|
||||
inputElementOrId?: string | HTMLInputElement | null,
|
||||
clearExternalState?: () => void
|
||||
) => {
|
||||
const fileName = fileData.displayedName ?? '(unnamed file)';
|
||||
console.log(`[clearFileInput] Clearing file input: ${fileName}`);
|
||||
|
||||
fileData.deleteTrigger = true;
|
||||
fileData.uploadState = false;
|
||||
fileData.previewState = false;
|
||||
fileData.oldPreviewState = false;
|
||||
fileData.preview = '';
|
||||
fileData.displayedName = '';
|
||||
fileData.input = null;
|
||||
|
||||
if (clearExternalState) {
|
||||
clearExternalState();
|
||||
}
|
||||
|
||||
if (typeof inputElementOrId === 'string') {
|
||||
const el = document.getElementById(inputElementOrId) as HTMLInputElement;
|
||||
if (el) el.value = '';
|
||||
} else if (inputElementOrId instanceof HTMLInputElement) {
|
||||
inputElementOrId.value = '';
|
||||
}
|
||||
|
||||
console.log('[clearFileInput] File input cleared');
|
||||
};
|
||||
34
bobu/app/utils/boardUtils/boardsNav.ts
Normal file
34
bobu/app/utils/boardUtils/boardsNav.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { fetchBoards } from './boardFetching';
|
||||
import type { BoardNavConfig } from '@/types/vueRefs';
|
||||
|
||||
export const goToPage = async (pageNumber: number, config: BoardNavConfig) => {
|
||||
config.currentPage.value = pageNumber;
|
||||
await fetchBoards(
|
||||
config.currentCollection,
|
||||
config.sortingOrder,
|
||||
config.userRole,
|
||||
config.currentItems,
|
||||
config.currentPage,
|
||||
config.itemsPerPage,
|
||||
config.access || 'public'
|
||||
);
|
||||
};
|
||||
|
||||
export const prevPage = async (config: BoardNavConfig) => {
|
||||
if (config.currentPage.value > 1) {
|
||||
await goToPage(config.currentPage.value - 1, config);
|
||||
}
|
||||
};
|
||||
|
||||
export const nextPage = async (config: BoardNavConfig) => {
|
||||
if (!config.totalCount) {
|
||||
console.warn('⚠️ totalCount is not provided in BoardNavConfig.');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPage = Math.ceil(config.totalCount.value / config.itemsPerPage);
|
||||
if (config.currentPage.value < maxPage) {
|
||||
await goToPage(config.currentPage.value + 1, config);
|
||||
}
|
||||
};
|
||||
48
bobu/app/utils/boardUtils/firestoreUpload.ts
Normal file
48
bobu/app/utils/boardUtils/firestoreUpload.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { BoardItem } from '@/types';
|
||||
import { doc, Timestamp, setDoc, updateDoc } from 'firebase/firestore';
|
||||
|
||||
export const getBoardDocRef = (currentCollection: any) => {
|
||||
const docRef = doc(currentCollection);
|
||||
return {
|
||||
docId: docRef.id,
|
||||
docRef,
|
||||
};
|
||||
};
|
||||
|
||||
// Create new BoardItem with timestamp and boards_number
|
||||
export const createBoardsData = (
|
||||
base: BoardItem,
|
||||
newBoardsNumber: number
|
||||
): BoardItem => ({
|
||||
...base,
|
||||
boards_number: base.boards_number || newBoardsNumber,
|
||||
created: base.created || Timestamp.now(),
|
||||
});
|
||||
// For Create
|
||||
export const handleCreateBoard = async (
|
||||
board: BoardItem,
|
||||
currentCollection: any
|
||||
) => {
|
||||
const docRef = doc(currentCollection);
|
||||
board.docId = docRef.id;
|
||||
|
||||
console.log(
|
||||
`[handleCreateBoard] Creating new board document with ID: ${board.docId}`
|
||||
);
|
||||
await setDoc(docRef, board);
|
||||
console.log(`[handleCreateBoard] Board created successfully`);
|
||||
|
||||
return docRef.id;
|
||||
};
|
||||
|
||||
// For Update
|
||||
export const handleUpdateBoard = async (
|
||||
board: BoardItem,
|
||||
currentCollection: any
|
||||
) => {
|
||||
console.log(
|
||||
`[handleUpdateBoard] Updating board document with ID: ${board.docId}`
|
||||
);
|
||||
await setDoc(doc(currentCollection, board.docId), board, { merge: true });
|
||||
console.log(`[handleUpdateBoard] Board updated successfully`);
|
||||
};
|
||||
7
bobu/app/utils/boardUtils/index.ts
Normal file
7
bobu/app/utils/boardUtils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './boardsActions'
|
||||
export * from './boardsNav'
|
||||
export * from './boardFetching'
|
||||
export * from './boardsFileUtils'
|
||||
export * from './firestoreUpload'
|
||||
export * from './storeUpload'
|
||||
export * from './boardsEmpty'
|
||||
167
bobu/app/utils/boardUtils/storeUpload.ts
Normal file
167
bobu/app/utils/boardUtils/storeUpload.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
ref as storageRef,
|
||||
uploadBytes,
|
||||
getDownloadURL,
|
||||
deleteObject,
|
||||
} from 'firebase/storage';
|
||||
import type { BoardItem, UploadFilesOptions, FileItem } from '@/types';
|
||||
import { UploadSettings } from '@/data/config';
|
||||
|
||||
// 3. Upload single file and return download URL , with upload progress ui
|
||||
export const uploadFileAndGetURL = async (
|
||||
file: File,
|
||||
path: string
|
||||
): Promise<string> => {
|
||||
const { $firebase } = useNuxtApp();
|
||||
const storage = $firebase.storage;
|
||||
const fileRef = storageRef(storage, path);
|
||||
console.log(`[uploadFileAndGetURL] Uploading file to: ${path}`);
|
||||
await uploadBytes(fileRef, file);
|
||||
const downloadURL = await getDownloadURL(fileRef);
|
||||
console.log(
|
||||
`[uploadFileAndGetURL] Uploaded and retrieved URL: ${downloadURL}`
|
||||
);
|
||||
return downloadURL;
|
||||
};
|
||||
|
||||
// 4. Delete a file from storage
|
||||
export const deleteUploadedFile = async (url: string) => {
|
||||
const { $firebase } = useNuxtApp();
|
||||
const storage = $firebase.storage;
|
||||
if (!url) return;
|
||||
const fileRef = storageRef(storage, url);
|
||||
console.log(`[deleteUploadedFile] Deleting file at: ${url}`);
|
||||
await deleteObject(fileRef);
|
||||
console.log(`[deleteUploadedFile] Deleted: ${url}`);
|
||||
};
|
||||
|
||||
// 5. Upload thumbnail
|
||||
export const handleThumbnailUpload = async ({
|
||||
newThumbnail,
|
||||
oldThumbnailUrl,
|
||||
deleteThumbnail,
|
||||
boardId,
|
||||
currentBoard,
|
||||
}: {
|
||||
newThumbnail: File | null;
|
||||
oldThumbnailUrl?: string;
|
||||
deleteThumbnail?: boolean;
|
||||
boardId: string;
|
||||
currentBoard: string;
|
||||
}): Promise<{ name: string; url: string } | null> => {
|
||||
if (newThumbnail && UploadSettings.isImageFile(newThumbnail)) {
|
||||
const thumbPath = `thumbnails/${currentBoard}/${boardId}/${newThumbnail.name}`;
|
||||
console.log(
|
||||
`[handleThumbnailUpload] Uploading thumbnail: ${newThumbnail.name}`
|
||||
);
|
||||
const thumbUrl = await uploadFileAndGetURL(newThumbnail, thumbPath);
|
||||
console.log(`[handleThumbnailUpload] Thumbnail uploaded: ${thumbUrl}`);
|
||||
return { name: newThumbnail.name, url: thumbUrl };
|
||||
}
|
||||
|
||||
if (deleteThumbnail && oldThumbnailUrl) {
|
||||
console.log(
|
||||
`[handleThumbnailUpload] Deleting existing thumbnail: ${oldThumbnailUrl}`
|
||||
);
|
||||
await deleteUploadedFile(oldThumbnailUrl);
|
||||
console.log(`[handleThumbnailUpload] Thumbnail deleted`);
|
||||
return { name: '', url: '' };
|
||||
}
|
||||
|
||||
console.log(`[handleThumbnailUpload] No change to thumbnail`);
|
||||
return null;
|
||||
};
|
||||
// 6. Upload or delete board files
|
||||
export const handleFileUpdates = async ({
|
||||
newFiles,
|
||||
oldFiles = [],
|
||||
deleteFileIndexes = [],
|
||||
currentBoard,
|
||||
boardId,
|
||||
}: {
|
||||
newFiles: File[];
|
||||
oldFiles?: FileItem[];
|
||||
deleteFileIndexes?: number[];
|
||||
currentBoard: string;
|
||||
boardId: string;
|
||||
}): Promise<FileItem[]> => {
|
||||
const result: FileItem[] = [];
|
||||
|
||||
console.log(
|
||||
`[handleFileUpdates] Retaining files: ${
|
||||
oldFiles.length - deleteFileIndexes.length
|
||||
}, Deleting: ${deleteFileIndexes.length}`
|
||||
);
|
||||
|
||||
for (const [idx, file] of oldFiles.entries()) {
|
||||
if (!deleteFileIndexes.includes(idx)) {
|
||||
result.push(file);
|
||||
} else {
|
||||
console.log(`[handleFileUpdates] Deleting file: ${file.name}`);
|
||||
await deleteUploadedFile(file.url);
|
||||
}
|
||||
}
|
||||
|
||||
const totalFileCount = result.length + newFiles.length;
|
||||
if (totalFileCount > UploadSettings.MAX_TOTAL_FILES) {
|
||||
console.error(
|
||||
`[handleFileUpdates] Upload exceeds max file count: ${totalFileCount}/${UploadSettings.MAX_TOTAL_FILES}`
|
||||
);
|
||||
throw new Error(
|
||||
`최대 ${UploadSettings.MAX_TOTAL_FILES}개의 파일만 업로드할 수 있습니다.`
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of newFiles) {
|
||||
const filePath = `boards/${currentBoard}/${boardId}/files/${file.name}`;
|
||||
console.log(
|
||||
`[handleFileUpdates] Uploading file: ${file.name} (${file.size} bytes)`
|
||||
);
|
||||
const url = await uploadFileAndGetURL(file, filePath);
|
||||
console.log(`[handleFileUpdates] File uploaded: ${url}`);
|
||||
result.push({ name: file.name, url });
|
||||
}
|
||||
|
||||
console.log(`[handleFileUpdates] Total files now: ${result.length}`);
|
||||
return result;
|
||||
};
|
||||
|
||||
// 7. Orchestrate full board file + thumbnail upload
|
||||
export const uploadFiles = async ({
|
||||
newBoardData,
|
||||
newThumbnail,
|
||||
newFiles,
|
||||
oldThumbnailUrl,
|
||||
oldFiles = [],
|
||||
deleteThumbnail = false,
|
||||
deleteFileIndexes = [],
|
||||
currentBoard,
|
||||
}: UploadFilesOptions): Promise<BoardItem> => {
|
||||
const boardId = newBoardData.docId;
|
||||
console.log(
|
||||
`[uploadFiles] Starting upload for boardId: ${boardId}, board: ${currentBoard}`
|
||||
);
|
||||
|
||||
const thumbnail = await handleThumbnailUpload({
|
||||
newThumbnail,
|
||||
oldThumbnailUrl,
|
||||
deleteThumbnail,
|
||||
boardId,
|
||||
currentBoard,
|
||||
});
|
||||
if (thumbnail) {
|
||||
console.log(`[uploadFiles] Thumbnail set: ${thumbnail.url}`);
|
||||
newBoardData.thumbnail = thumbnail;
|
||||
}
|
||||
|
||||
newBoardData.files = await handleFileUpdates({
|
||||
newFiles,
|
||||
oldFiles,
|
||||
deleteFileIndexes,
|
||||
currentBoard,
|
||||
boardId,
|
||||
});
|
||||
|
||||
console.log(`[uploadFiles] Upload complete for board: ${boardId}`);
|
||||
return newBoardData;
|
||||
};
|
||||
138
bobu/app/utils/ckeditorUtils.ts
Normal file
138
bobu/app/utils/ckeditorUtils.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { markRaw } from 'vue';
|
||||
import type { EditorConfig } from '@ckeditor/ckeditor5-core';
|
||||
import type ClassicEditor from '@ckeditor/ckeditor5-build-classic';
|
||||
import type { Component } from 'vue';
|
||||
import FirebaseUploadAdapterPlugin from '@/utils/boardUtils/FirebaseUploadAdapter';
|
||||
|
||||
export interface CKEditorInstances {
|
||||
editor: typeof ClassicEditor | null;
|
||||
ckeditor: Component | null;
|
||||
}
|
||||
|
||||
// Module-level cache
|
||||
let editorInstance: typeof ClassicEditor | null = null;
|
||||
let ckeditorInstance: Component | null = null;
|
||||
|
||||
export const loadCKEditor = async (): Promise<CKEditorInstances> => {
|
||||
// Check cache first
|
||||
if (!editorInstance || !ckeditorInstance) {
|
||||
console.log('[CKEditor Utils] Loading fresh instances...');
|
||||
try {
|
||||
// Dynamically import the modules
|
||||
const ClassicEditorModule = await import(
|
||||
'@ckeditor/ckeditor5-build-classic'
|
||||
);
|
||||
const CKEditorModule = await import('@ckeditor/ckeditor5-vue');
|
||||
|
||||
// Assign the editor constructor
|
||||
if (ClassicEditorModule && ClassicEditorModule.default) {
|
||||
editorInstance = markRaw(ClassicEditorModule.default);
|
||||
} else {
|
||||
console.error(
|
||||
'[CKEditor Utils] ClassicEditorModule.default not found.'
|
||||
);
|
||||
throw new Error('ClassicEditorModule default export not found.');
|
||||
}
|
||||
|
||||
// Assign the CKEditor Vue component based on named export 'Ckeditor'
|
||||
let componentDefinition: Component | null = null;
|
||||
if (CKEditorModule && CKEditorModule.Ckeditor) {
|
||||
// Check if the named export 'Ckeditor' exists
|
||||
// Assume Ckeditor export *is* the component, as the .component check caused TS errors
|
||||
componentDefinition = CKEditorModule.Ckeditor as Component;
|
||||
console.log(
|
||||
'[CKEditor Utils] Accessed component via CKEditorModule.Ckeditor (Simplified)'
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
"[CKEditor Utils] Could not find 'Ckeditor' named export in CKEditorModule."
|
||||
);
|
||||
// Consider throwing an error if the component is essential
|
||||
throw new Error(
|
||||
"CKEditor Vue component 'Ckeditor' named export not found."
|
||||
);
|
||||
}
|
||||
|
||||
// Assign the found component, wrapped in markRaw
|
||||
ckeditorInstance = componentDefinition
|
||||
? markRaw(componentDefinition)
|
||||
: null;
|
||||
|
||||
// Verify instances were assigned
|
||||
if (!editorInstance || !ckeditorInstance) {
|
||||
console.error('[CKEditor Utils] Instance assignment failed.', {
|
||||
editorInstance,
|
||||
ckeditorInstance,
|
||||
});
|
||||
throw new Error(
|
||||
'CKEditor instance assignment resulted in null values.'
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[CKEditor Utils] Instances loaded and cached.');
|
||||
} catch (error) {
|
||||
console.error('Error loading CKEditor modules:', error);
|
||||
// Ensure cache is cleared on error
|
||||
editorInstance = null;
|
||||
ckeditorInstance = null;
|
||||
throw error; // Re-throw error to be caught by caller
|
||||
}
|
||||
} else {
|
||||
console.log('[CKEditor Utils] Returning cached instances.');
|
||||
}
|
||||
|
||||
// Final check should be redundant if error handling is correct
|
||||
if (!editorInstance || !ckeditorInstance) {
|
||||
throw new Error(
|
||||
'Attempted to return CKEditor instances but they are null.'
|
||||
);
|
||||
}
|
||||
|
||||
// Return the cached or newly loaded instances
|
||||
return {
|
||||
editor: editorInstance,
|
||||
ckeditor: ckeditorInstance,
|
||||
};
|
||||
};
|
||||
|
||||
// Editor Configuration (remains the same)
|
||||
export const editorConfig: Partial<EditorConfig> = {
|
||||
extraPlugins: [FirebaseUploadAdapterPlugin],
|
||||
toolbar: {
|
||||
items: [
|
||||
'heading',
|
||||
'|',
|
||||
'bold',
|
||||
'italic',
|
||||
'link',
|
||||
'|',
|
||||
'bulletedList',
|
||||
'numberedList',
|
||||
'blockQuote',
|
||||
'|',
|
||||
'uploadImage',
|
||||
'insertTable',
|
||||
'mediaEmbed',
|
||||
'|',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
language: 'ko',
|
||||
mediaEmbed: { previewsInData: true },
|
||||
image: {
|
||||
toolbar: [
|
||||
'imageTextAlternative',
|
||||
'toggleImageCaption',
|
||||
'|',
|
||||
'imageStyle:inline',
|
||||
'imageStyle:block',
|
||||
'imageStyle:side',
|
||||
'|',
|
||||
'linkImage',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Export the loader function as the default
|
||||
export default loadCKEditor;
|
||||
21
bobu/app/utils/firebaseHelpers.ts
Normal file
21
bobu/app/utils/firebaseHelpers.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function handleAuthError(error: any): string {
|
||||
const code = error.code;
|
||||
switch (code) {
|
||||
case 'auth/email-already-in-use':
|
||||
return '이미 사용 중인 이메일입니다.';
|
||||
case 'auth/invalid-email':
|
||||
return '유효하지 않은 이메일 주소입니다.';
|
||||
case 'auth/user-not-found':
|
||||
return '해당 사용자를 찾을 수 없습니다.';
|
||||
case 'auth/wrong-password':
|
||||
return '비밀번호가 일치하지 않습니다.';
|
||||
case 'auth/weak-password':
|
||||
return '비밀번호는 최소 6자 이상이어야 합니다.';
|
||||
default:
|
||||
return '알 수 없는 오류가 발생했습니다.';
|
||||
}
|
||||
}
|
||||
319
bobu/app/utils/firebaseUtils.ts
Normal file
319
bobu/app/utils/firebaseUtils.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
getDocs,
|
||||
query,
|
||||
orderBy,
|
||||
limit,
|
||||
CollectionReference,
|
||||
Timestamp,
|
||||
addDoc,
|
||||
updateDoc,
|
||||
doc,
|
||||
deleteDoc,
|
||||
} from 'firebase/firestore';
|
||||
import {
|
||||
ref as storageRef,
|
||||
uploadBytes,
|
||||
getDownloadURL,
|
||||
deleteObject,
|
||||
} from 'firebase/storage';
|
||||
import type { DocumentData } from 'firebase/firestore';
|
||||
import { httpsCallable } from 'firebase/functions';
|
||||
import type { CreateProgressResponse, CertificateResponse } from '@/types';
|
||||
const FUNCTIONS_BASE_URL = import.meta.env.VITE_FUNCTIONS_BASE_URL;
|
||||
|
||||
export const fetchLatestDocumentNumber = async (
|
||||
collectionRef: CollectionReference<DocumentData>,
|
||||
orderByField: string
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const q = query(collectionRef, orderBy(orderByField, 'desc'), limit(1));
|
||||
const querySnapshot = await getDocs(q);
|
||||
|
||||
if (!querySnapshot.empty) {
|
||||
const doc = querySnapshot.docs[0];
|
||||
if (!doc) return 1;
|
||||
const data = doc.data();
|
||||
const value = data[orderByField];
|
||||
return (typeof value === 'number' ? value : 0) + 1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching the latest document number:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
// Uploads a file to Firebase Storage and returns its download URL.
|
||||
export const uploadFileAndGetURL = async (
|
||||
file: File | null,
|
||||
path: string
|
||||
): Promise<string | null> => {
|
||||
const { $firebase } = useNuxtApp();
|
||||
const storage = $firebase.storage;
|
||||
|
||||
try {
|
||||
if (!file) return null;
|
||||
const fileRef = storageRef(storage, path); // path inside the bucket
|
||||
await uploadBytes(fileRef, file); // upload the file
|
||||
return await getDownloadURL(fileRef); // get the URL after upload
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
// Delete a file from Firebase Storage by its path
|
||||
|
||||
export async function deleteFileFromStorage(path: string): Promise<void> {
|
||||
const { $firebase } = useNuxtApp();
|
||||
const storage = $firebase.storage;
|
||||
|
||||
try {
|
||||
const fileRef = storageRef(storage, path);
|
||||
await deleteObject(fileRef);
|
||||
} catch (error) {
|
||||
console.error('Error deleting file from storage:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
//Add a new document to Firestore
|
||||
|
||||
export async function uploadDataToFirestore(
|
||||
collectionRef: CollectionReference<DocumentData>,
|
||||
data: Record<string, any>
|
||||
) {
|
||||
try {
|
||||
return await addDoc(collectionRef, data);
|
||||
} catch (error) {
|
||||
console.error('Error uploading data to Firestore:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
//Update a document in Firestore by its ID
|
||||
|
||||
export async function updateDataInFirestore(
|
||||
collectionRef: CollectionReference<DocumentData>,
|
||||
id: string,
|
||||
data: Record<string, any>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const docRef = doc(collectionRef, id);
|
||||
await updateDoc(docRef, data);
|
||||
} catch (error) {
|
||||
console.error('Error updating data in Firestore:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Deletes a specific document from a Firestore collection.
|
||||
export async function deleteDataFromFirestore(
|
||||
collectionRef: CollectionReference,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const docRef = doc(collectionRef, id);
|
||||
await deleteDoc(docRef);
|
||||
} catch (error) {
|
||||
console.error('Error deleting data from Firestore:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteUserFromAuthentication = async (uid: string) => {
|
||||
console.log('firebasefunctioncalled', uid);
|
||||
const { $firebase } = useNuxtApp();
|
||||
const functions = $firebase.functions;
|
||||
const deleteUserFunction = httpsCallable(functions, 'deleteUser');
|
||||
|
||||
try {
|
||||
const result = await deleteUserFunction({ uid: 'some-user-id' });
|
||||
console.log(
|
||||
`User with UID: ${uid} has been deleted from Firebase Authentication.`
|
||||
);
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
export function formatDate(timestamp: Timestamp): string {
|
||||
// Check if timestamp is not provided or not a valid Firebase Timestamp
|
||||
if (!timestamp || !(timestamp instanceof Timestamp)) {
|
||||
console.warn('Invalid timestamp provided to formatDate function.');
|
||||
return '';
|
||||
}
|
||||
|
||||
let date;
|
||||
try {
|
||||
date = timestamp.toDate();
|
||||
} catch (error) {
|
||||
console.error('Error converting timestamp to date:', error);
|
||||
return '';
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed in JS
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
export function formatKoreanDate(timestamp: Timestamp): string {
|
||||
// Check if timestamp is not provided or not a valid Firebase Timestamp
|
||||
if (!timestamp || !(timestamp instanceof Timestamp)) {
|
||||
console.warn('Invalid timestamp provided to formatDate function.');
|
||||
return '';
|
||||
}
|
||||
|
||||
let date;
|
||||
try {
|
||||
date = timestamp.toDate();
|
||||
} catch (error) {
|
||||
console.error('Error converting timestamp to date:', error);
|
||||
return '';
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed in JS
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}년 ${month}월 ${day}일`;
|
||||
}
|
||||
//check email duplicate before Auth
|
||||
export const checkEmailDuplicate = async (email: string) => {
|
||||
try {
|
||||
console.log('atfirebaseUtils,email is', email);
|
||||
console.log(
|
||||
'FUNCTIONS_BASE_URL',
|
||||
`${FUNCTIONS_BASE_URL}/checkEmailDuplicate`
|
||||
);
|
||||
const response = await $fetch<{ status: string; message?: string }>(
|
||||
`${FUNCTIONS_BASE_URL}/checkEmailDuplicate`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { email: email },
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 'exists') {
|
||||
return { status: 'exists' };
|
||||
} else if (response.status === 'available') {
|
||||
return { status: 'available' };
|
||||
} else {
|
||||
throw new Error(
|
||||
response.message || 'Unexpected response from the server.'
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error checking email:', error);
|
||||
return { status: 'error', message: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
export const generateCertificate = async (
|
||||
name: string,
|
||||
courseTitle: string,
|
||||
finishedDate: string
|
||||
): Promise<{
|
||||
imageUrl?: string;
|
||||
pdfUrl?: string;
|
||||
uniqueId?: string;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
console.log(
|
||||
'generateFunction called with',
|
||||
name,
|
||||
'name',
|
||||
courseTitle,
|
||||
'coursetitle',
|
||||
finishedDate,
|
||||
'finishedDate'
|
||||
);
|
||||
const result = await certificateFunction({
|
||||
name,
|
||||
courseTitle,
|
||||
completeAt: finishedDate,
|
||||
});
|
||||
|
||||
const responseData = result.data as CertificateResponse;
|
||||
|
||||
if (responseData.status === 'success' && responseData.message) {
|
||||
console.log('Image URL:', responseData.message.imageUrl);
|
||||
console.log('PDF URL:', responseData.message.pdfUrl);
|
||||
console.log('Unique ID:', responseData.message.uniqueId);
|
||||
return {
|
||||
imageUrl: responseData.message.imageUrl,
|
||||
pdfUrl: responseData.message.pdfUrl,
|
||||
uniqueId: responseData.message.uniqueId,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to generate certificate');
|
||||
return {
|
||||
error: 'Failed to generate certificate',
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error calling the function:', error);
|
||||
return {
|
||||
error: error.message || 'Error calling the function',
|
||||
};
|
||||
}
|
||||
};
|
||||
export const createProgressFunction = async (
|
||||
data: any
|
||||
): Promise<CreateProgressResponse> => {
|
||||
const { $firebase } = useNuxtApp();
|
||||
const functions = $firebase.functions;
|
||||
const callableFunction = httpsCallable(functions, 'createProgress');
|
||||
|
||||
try {
|
||||
const response = await callableFunction(data);
|
||||
return response.data as CreateProgressResponse; // Type-casting the response data
|
||||
} catch (error) {
|
||||
console.error('Error calling createProgress function:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
export const getResizedImageURL = async (
|
||||
originalURL: string,
|
||||
originalName: string
|
||||
): Promise<string> => {
|
||||
const { $firebase } = useNuxtApp();
|
||||
const storage = $firebase.storage;
|
||||
|
||||
// Decode the originalURL
|
||||
const decodedURL = decodeURIComponent(originalURL);
|
||||
|
||||
// Extract the base part of the name without extension
|
||||
const baseName = originalName.split('.').slice(0, -1).join('.');
|
||||
|
||||
// Construct the expected name for the resized image
|
||||
const resizedName = `${baseName}_500x500.webp`;
|
||||
|
||||
// Extract the course name from the decoded URL
|
||||
const courseNameMatch = decodedURL.match(/thumbnail\/(.*?)\/[^/]+$/);
|
||||
const courseName = courseNameMatch ? courseNameMatch[1] : '';
|
||||
|
||||
// Construct the expected path for the resized image
|
||||
const expectedPath = `boards/course/thumbnail/${courseName}/${resizedName}`;
|
||||
|
||||
// Check if the resized image exists
|
||||
try {
|
||||
const resizedImageRef = storageRef(storage, expectedPath);
|
||||
const resizedImageURL = await getDownloadURL(resizedImageRef);
|
||||
return resizedImageURL;
|
||||
} catch (error) {
|
||||
// If the resized image does not exist, return the original URL
|
||||
return originalURL;
|
||||
}
|
||||
};
|
||||
|
||||
//certificate generate call to firebase cloud functions
|
||||
const certificateFunction = (data: {
|
||||
name: string;
|
||||
courseTitle: string;
|
||||
completeAt: string;
|
||||
}) => {
|
||||
const { $firebase } = useNuxtApp();
|
||||
const functions = $firebase.functions;
|
||||
return httpsCallable(functions, 'generateCertificate')(data);
|
||||
};
|
||||
18
bobu/firebase.json
Normal file
18
bobu/firebase.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"functions": {
|
||||
"source": ".output/server"
|
||||
},
|
||||
"hosting": [
|
||||
{
|
||||
"site": "<your_project_id>",
|
||||
"public": ".output/public",
|
||||
"cleanUrls": true,
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "**",
|
||||
"function": "server"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
38
bobu/i18n/locales/en.json
Normal file
38
bobu/i18n/locales/en.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "Bobu Normad",
|
||||
"description": "We blend technology and the arts to build immersive, human-centered media.",
|
||||
"sectionLabel": "What We Do",
|
||||
"sectionTitle": "Pioneering creative tech for social impact",
|
||||
"mission": "We foster a healthy cultural ecosystem through inclusive art and media projects, reaching marginalized communities.",
|
||||
"vision": "We transform creative ideas into reality — with detail, and then more detail.",
|
||||
"portfolio": "Our works merge technology and art — creating immersive stories with VR, AR, 3D, and video that leave lasting impressions.",
|
||||
"quote": "Creative, Innovative, and Impactful",
|
||||
"cta": "Explore our projects",
|
||||
"ceoQuote": "Through meticulous attention to detail, we amplify the depth of art and the potential of technology. By creating content on a new level, we deliver unforgettable and meaningful experiences.",
|
||||
"ceo": "Jun Ho Ma",
|
||||
"ceoTitle": "CTO, Manos Social Cooperative",
|
||||
|
||||
"teamTitle": "Meet Our Team",
|
||||
"teamSubtitle": "A dedicated group bridging culture and technology",
|
||||
|
||||
"junhoName": "Jun Ho Ma",
|
||||
"junhoRole": "Director, Secretary-General",
|
||||
"junhoDesc": "Leads all directing and production",
|
||||
|
||||
"juhyeName": "Juhye Lee",
|
||||
"juhyeRole": "Director",
|
||||
"juhyeDesc": "Researches inclusive education for marginalized groups",
|
||||
|
||||
"eonsuName": "Eonsu Joo",
|
||||
"eonsuRole": "Production Lead",
|
||||
"eonsuDesc": "The magic-maker behind our productions",
|
||||
|
||||
"seyoungName": "Seyoung Yoo",
|
||||
"seyoungRole": "Operations Lead",
|
||||
"seyoungDesc": "Ensures our work makes a social impact"
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Welcome to our site!"
|
||||
}
|
||||
}
|
||||
4
bobu/i18n/locales/en/about.json
Normal file
4
bobu/i18n/locales/en/about.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Bobu Normad",
|
||||
"description": "We blend technology and the arts to build immersive, human-centered media. From video to VR, AR, and 3D content, we aim to inspire and innovate through every project."
|
||||
}
|
||||
38
bobu/i18n/locales/ko.json
Normal file
38
bobu/i18n/locales/ko.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "마노스 크리에이티브",
|
||||
"description": "기술과 예술을 융합하여 몰입감 있고 사람 중심의 미디어를 만듭니다.",
|
||||
"sectionLabel": "우리가 하는 일",
|
||||
"sectionTitle": "사회적 임팩트를 위한 창의기술 개척자",
|
||||
"mission": "취약계층을 위한 예술 및 미디어 프로젝트를 통해 모두가 함께하는 건강한 문화 생태계를 조성합니다.",
|
||||
"vision": "창의적인 아이디어를 현실로 — 디테일, 그리고 또 디테일을 더해 만듭니다.",
|
||||
"portfolio": "우리는 기술과 예술을 융합해 VR, AR, 3D, 영상 등 몰입형 스토리로 감동을 전합니다.",
|
||||
"quote": "창의적, 혁신적, 그리고 임팩트 있는",
|
||||
"cta": "프로젝트 살펴보기",
|
||||
"ceoQuote": "작은 디테일까지 놓치지 않는 정교한 작업으로, 예술의 깊이와 기술의 가능성을 극대화합니다. 새로운 차원의 콘텐츠를 통해, 사람들에게 기억에 남을 특별한 경험을 제공합니다.",
|
||||
"ceo": "박상주",
|
||||
"ceoTitle": "대표, 마노스 사회적협동조합",
|
||||
|
||||
"teamTitle": "우리 팀을 소개합니다",
|
||||
"teamSubtitle": "문화와 기술의 경계를 넘나드는 헌신적인 사람들",
|
||||
|
||||
"junhoName": "마준호",
|
||||
"junhoRole": "이사, 사무국장",
|
||||
"junhoDesc": "연출과 제작을 총괄합니다.",
|
||||
|
||||
"juhyeName": "이주혜",
|
||||
"juhyeRole": "이사",
|
||||
"juhyeDesc": "취약계층 대상 포용적 교육을 연구합니다.",
|
||||
|
||||
"eonsuName": "주언수",
|
||||
"eonsuRole": "제작팀장",
|
||||
"eonsuDesc": "모든 제작의 마법을 담당합니다.",
|
||||
|
||||
"seyoungName": "유세영",
|
||||
"seyoungRole": "운영팀장",
|
||||
"seyoungDesc": "사회적 임팩트를 실현하는 운영을 책임집니다."
|
||||
},
|
||||
"home": {
|
||||
"welcome": "저희 사이트에 오신 것을 환영합니다!"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user