first commit

This commit is contained in:
2025-06-03 21:13:56 +09:00
commit e91d481216
171 changed files with 45905 additions and 0 deletions

5
.firebaserc Normal file
View File

@@ -0,0 +1,5 @@
{
"projects": {
"default": "normadbobu"
}
}

69
.gitignore vendored Normal file
View 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
View 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
View 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
View 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>

View 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>

View 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>

View 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>

View 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"
>
&copy; {{ 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>

View 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>

View 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>

View 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 i18ns 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>

View 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>

View 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>

View 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">&rarr;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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? Wed 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>

View 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']);
// well 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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/permissiondenied.
*/
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
}

View 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,
};
}

View 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); // 1based 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 (page1) -------------------- */
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 sortorder 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,
};
}

View 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,
};
};

View 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 signedin, nothing to wait for
if (auth.currentUser) return;
// Otherwise wait (or timeout after 5s)
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
View 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
View 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
View 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;

View 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>

View 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>

View 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
View File

@@ -0,0 +1,8 @@
<template>
<div>
<AboutSection1 />
<AboutSection3 />
</div>
</template>
<script setup lang="ts"></script>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,6 @@
<template>
<div>
<ContactUs />
</div>
</template>
<script setup lang="ts"></script>

12
bobu/app/pages/index.vue Normal file
View 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
View File

@@ -0,0 +1,6 @@
<template>
<AdminLogin />
</template>
<script setup lang="ts">
import AdminLogin from "~/components/auth/admin-login.vue";
</script>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,7 @@
<template>
<div>
<h1>공유 오피스 예약</h1>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,2 @@
<template><div>hi</div></template>
<script setup lang="ts"></script>

View 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 accesscontrol
itemsPerPage: 100, // big enough to fetch them all
sortOrder: 'desc',
pageNumber: 1,
}
);
// keep only announcement (or whatever clientside 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
View File

@@ -0,0 +1,7 @@
<template>
<div>
<h1>Shop</h1>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,5 @@
<template>
<div>
<h1>Unauthorized</h1>
</div>
</template>

43
bobu/app/pages/wadiz.vue Normal file
View 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>

View File

@@ -0,0 +1,7 @@
<template>
<div>
<h1>워크스테이션 프로그램</h1>
</div>
</template>
<script setup lang="ts"></script>

View 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);
});

View 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
});

View 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);
});

View 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)
});
});

View 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)
}
}
})

View 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
View 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
},
},
});

View 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
View 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
View 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

View 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
View File

@@ -0,0 +1,3 @@
export * from './boardItem';
export * from './firebaseTypes';
export * from './vueRefs';

7
bobu/app/types/naver.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
export {}; // make this file a module
declare global {
interface Window {
naver: any; // or more specific types if youd like
}
}

9
bobu/app/types/nuxt.d.ts vendored Normal file
View 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
View 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>;
}

View 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',
});
}

View 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);
}

View 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,
});
}

View 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
});
}

View 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);
};
}

View 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;
}
}

View 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();
};

View 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: [],
// };
// }

View 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');
};

View 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);
}
};

View 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`);
};

View 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'

View 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;
};

View 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;

View 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 '알 수 없는 오류가 발생했습니다.';
}
}

View 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
View 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
View 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!"
}
}

View 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
View 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