mirror of
https://github.com/sunrin-ana/2025-SSF-Frontend.git
synced 2026-03-09 18:30:00 +00:00
test
This commit is contained in:
parent
6c6a88931c
commit
afe581ec34
69 changed files with 12702 additions and 25 deletions
20
Dockerfile
Normal file
20
Dockerfile
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
9801
package-lock.json
generated
Normal file
9801
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -26,16 +26,25 @@
|
|||
"@builder.io/qwik": "^1.15.0",
|
||||
"@builder.io/qwik-city": "^1.15.0",
|
||||
"@eslint/js": "latest",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "20.14.11",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "9.25.1",
|
||||
"eslint-plugin-qwik": "^1.15.0",
|
||||
"globals": "16.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "3.3.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "5.4.5",
|
||||
"typescript-eslint": "8.26.1",
|
||||
"typescript-plugin-css-modules": "latest",
|
||||
"undici": "*",
|
||||
"vite": "5.3.5",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.11.0",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
postcss.config.js
Normal file
11
postcss.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
// const config = {
|
||||
// plugins: ["@tailwindcss/postcss"],
|
||||
// };
|
||||
|
||||
// export default config;
|
||||
93
src/assets/fonts/WantedSans-1.0.3/OFL.txt
Normal file
93
src/assets/fonts/WantedSans-1.0.3/OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2024 The Wanted Sans Project Authors (https://github.com/wanteddev/wanted-sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-Black.otf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-Black.otf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-Bold.otf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-Bold.otf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-ExtraBlack.otf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-ExtraBlack.otf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-ExtraBold.otf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-ExtraBold.otf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-Medium.otf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-Medium.otf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-Regular.otf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-Regular.otf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-SemiBold.otf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/otf/WantedSans-SemiBold.otf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-Black.ttf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-Black.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-Bold.ttf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-Bold.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-ExtraBlack.ttf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-ExtraBlack.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-ExtraBold.ttf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-Medium.ttf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-Medium.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-Regular.ttf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-Regular.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-SemiBold.ttf
Normal file
BIN
src/assets/fonts/WantedSans-1.0.3/ttf/WantedSans-SemiBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
Copyright (c) 2024 Wanted Lab, Inc., with Reserved Font Name Wanted Sans.
|
||||
https://github.com/wanteddev/wanted-sans
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
*/
|
||||
@font-face {
|
||||
font-family: "Wanted Sans";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 1000;
|
||||
src: local("Wanted Sans ExtraBlack"), url("./woff2/WantedSans-ExtraBlack.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Wanted Sans";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 900;
|
||||
src: local("Wanted Sans Black"), url("./woff2/WantedSans-Black.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Wanted Sans";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 800;
|
||||
src: local("Wanted Sans ExtraBold"), url("./woff2/WantedSans-ExtraBold.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Wanted Sans";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src: local("Wanted Sans Bold"), url("./woff2/WantedSans-Bold.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Wanted Sans";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: local("Wanted Sans SemiBold"), url("./woff2/WantedSans-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Wanted Sans";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src: local("Wanted Sans Medium"), url("./woff2/WantedSans-Medium.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Wanted Sans";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: local("Wanted Sans Regular"), url("./woff2/WantedSans-Regular.woff2") format("woff2");
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
Copyright (c) 2024 Wanted Lab, Inc., with Reserved Font Name Wanted Sans Variable.
|
||||
https://github.com/wanteddev/wanted-sans
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
*/
|
||||
@font-face {
|
||||
font-family: "Wanted Sans Variable";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400 1000;
|
||||
src: url("./woff2/WantedSansVariable.woff2") format("woff2-variations");
|
||||
}
|
||||
Binary file not shown.
4
src/assets/images/terminate.svg
Normal file
4
src/assets/images/terminate.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="23" height="26" viewBox="0 0 23 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.5944 0V12.6875H12.4069V0H10.5944Z" fill="black"/>
|
||||
<path d="M2.43815 14.1593C2.43791 12.5407 2.87115 10.9516 3.69286 9.55706C4.51458 8.16257 5.69477 7.0136 7.11078 6.22956L6.23172 4.64544C4.13017 5.80918 2.47309 7.63602 1.51917 9.84075C0.565252 12.0455 0.368182 14.504 0.958726 16.8326C1.54927 19.1611 2.89419 21.2285 4.78353 22.7122C6.67287 24.1958 9.00028 25.0122 11.4024 25.0337C13.8046 25.0553 16.1463 24.2808 18.0619 22.8313C19.9776 21.3818 21.3594 19.3388 21.9916 17.0213C22.6238 14.7037 22.4709 12.242 21.5567 10.0205C20.6425 7.79904 19.0185 5.94277 16.9382 4.7415L16.0301 6.30931C17.408 7.10443 18.5523 8.24826 19.3481 9.62582C20.1438 11.0034 20.5629 12.5661 20.5632 14.157C20.5635 15.7479 20.145 17.3108 19.3498 18.6887C18.5546 20.0665 17.4107 21.2108 16.033 22.0064C14.6554 22.802 13.0926 23.221 11.5018 23.2212C9.91091 23.2214 8.34802 22.8028 6.9702 22.0075C5.59239 21.2122 4.44821 20.0683 3.65266 18.6906C2.85712 17.3129 2.43825 15.7501 2.43815 14.1593Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
121
src/components/features/AddFriends.tsx
Normal file
121
src/components/features/AddFriends.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { component$, Signal, useSignal, $, useTask$, } from "@builder.io/qwik";
|
||||
import axios from "axios";
|
||||
import { getCookie } from "~/utils/cookie";
|
||||
export const AddFriends = component$(({ showAddFriends }: { showAddFriends: Signal<boolean> }) => {
|
||||
const friends = useSignal([]) as Signal<any[]>;
|
||||
const myfriends = useSignal([]) as Signal<any[]>;
|
||||
const searchQuery = useSignal('');
|
||||
useTask$(() => {
|
||||
try {
|
||||
axios.get(`http://localhost:8000/api/friendship`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
myfriends.value = res.data;
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
})
|
||||
const handleAddFriend = $((username: string) => {
|
||||
try {
|
||||
axios.post(`http://localhost:8000/api/friendship/request`, {
|
||||
friend_username: username,
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
|
||||
})
|
||||
} catch (error : any) {
|
||||
console.log(error.response?.data?.detail);
|
||||
}
|
||||
})
|
||||
const handleSearch = $((query: string) => {
|
||||
try {
|
||||
axios.get(`http://localhost:8000/api/user/find/${query}`, {
|
||||
})
|
||||
.then((res) => {
|
||||
friends.value = res.data;
|
||||
})
|
||||
} catch (error : any) {
|
||||
console.log(error);
|
||||
}
|
||||
})
|
||||
const handleDeleteFriend = $((friendship_id : number) => {
|
||||
try {
|
||||
axios.delete(`http://localhost:8000/api/friendship/${friendship_id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
console.log(res.data);
|
||||
myfriends.value = myfriends.value.filter((friend) => friend.id !== friendship_id);
|
||||
})
|
||||
} catch (error : any) {
|
||||
console.log(error.response?.data?.detail);
|
||||
}
|
||||
})
|
||||
return (
|
||||
<div onclick$={() => {showAddFriends.value = false; console.log(showAddFriends.value)}} class="bg-opacity-45 z-[1000] fixed inset-0 w-full h-full py-6 px-6 bg-black">
|
||||
<div class="flex w-full h-full justify-center items-center">
|
||||
<div onclick$={(e) => {e.stopPropagation()}} class="max-w-[1001px] w-full bg-white rounded-lg shadow p-4">
|
||||
{/* 헤더 */}
|
||||
<div class="flex justify-between items-center mb-3 border-b pb-2 border-black">
|
||||
<h1 class="text-3xl font-bold">친구 추가</h1>
|
||||
<button onclick$={() => {showAddFriends.value = false; console.log(showAddFriends.value)}} class="text-black text-xl"><i class="bi bi-x-square"></i></button>
|
||||
</div>
|
||||
|
||||
{/* 검색창 */}
|
||||
<form class="relative" preventdefault:submit onSubmit$={() => {handleSearch(searchQuery.value)}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="닉네임/아이디 검색"
|
||||
class="w-full border border-black text-text-default rounded px-10 py-2 focus:outline-none focus:ring-1 focus:ring-[#4b3c28]"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<button class="absolute left-2 top-2 text-black hover:text-gray-600 text-xl"><i class="bi bi-search"></i></button>
|
||||
</form>
|
||||
{/* 검색 결과 */}
|
||||
<p class="text-lg font-bold p-2">검색 결과 {friends.value.length}건</p>
|
||||
<div class="divide-y divide-gray-200 max-h-64 overflow-y-auto">
|
||||
{friends.value.map((friend, i) => (
|
||||
<div key={i} class="flex items-center justify-between py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<img class={`w-8 h-8 rounded-full`} src={`http://localhost:8000/${friend.profile_image_path}`} />
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-sm">{friend.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-black hover:text-red-500" onclick$={() => {handleAddFriend(friend.username)}}><i class="bi bi-plus"></i></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="w-full border-b-2 border-y-2 p-1 flex justify-between border-gray-400">
|
||||
<p class="text-lg font-bold">친구 목록</p>
|
||||
{/* <i class="bi bi-arrow-clockwise"></i> */}
|
||||
</div>
|
||||
{/* 이미 추가된 친구들 */}
|
||||
<div class="divide-y divide-gray-200 max-h-64 overflow-y-auto">
|
||||
{myfriends.value.map((friend, i) => (
|
||||
<div key={i} class="flex items-center justify-between py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
{/* <img class={`w-8 h-8 rounded-full`} src={`http://localhost:8000/${friend.profile_image_path}`} /> */}
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-sm">{friend.friend_username}</span>
|
||||
<span class="text-xs text-gray-500">{friend.friend_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-black hover:text-red-500" onclick$={() => {handleDeleteFriend(friend.id)}}><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
0
src/components/features/ManageFriends.tsx
Normal file
0
src/components/features/ManageFriends.tsx
Normal file
10
src/components/features/NoticeBoard.tsx
Normal file
10
src/components/features/NoticeBoard.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { component$ } from "@builder.io/qwik";
|
||||
|
||||
export const NoticeBoard = component$(() => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Update</h1>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
43
src/components/features/diary/UploadImages.tsx
Normal file
43
src/components/features/diary/UploadImages.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// src/components/PhotoUploadModal.tsx
|
||||
|
||||
import { component$, useSignal, Signal, $ } from '@builder.io/qwik';
|
||||
|
||||
type FileInfo = {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
lastModified: number;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export const PhotoUploadModal = component$(({ selectedImages, showPhotoUploadModal }: { selectedImages: Signal<File[]>, showPhotoUploadModal: Signal<boolean> }) => {
|
||||
|
||||
const fileInfo = useSignal<FileInfo | null>(null);
|
||||
const fileRef = useSignal<File | null>(null);
|
||||
const error = useSignal('');
|
||||
const fileInputRef = useSignal<HTMLInputElement>();
|
||||
|
||||
const handleFileSelect = $(async (selectedFile: File) => {
|
||||
if (selectedFile && selectedFile.type.startsWith('image/')) {
|
||||
fileInfo.value = {
|
||||
name: selectedFile.name,
|
||||
size: selectedFile.size,
|
||||
type: selectedFile.type,
|
||||
lastModified: selectedFile.lastModified,
|
||||
url: URL.createObjectURL(selectedFile)
|
||||
};
|
||||
fileRef.value = selectedFile;
|
||||
error.value = '';
|
||||
} else {
|
||||
error.value = '이미지 파일만 업로드 가능합니다.';
|
||||
fileInfo.value = null;
|
||||
fileRef.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (!showPhotoUploadModal.value) return null;
|
||||
|
||||
return (
|
||||
|
||||
);
|
||||
});
|
||||
29
src/components/features/guestbook/DeleteModal.tsx
Normal file
29
src/components/features/guestbook/DeleteModal.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { $, component$, Signal } from "@builder.io/qwik";
|
||||
import axios from "axios";
|
||||
import { getCookie } from "~/utils/cookie";
|
||||
export default component$(( { messages, showDeleteModal, selectedMessage }: { messages: Signal<any>, showDeleteModal: Signal<boolean>, selectedMessage: Signal<any> }) => {
|
||||
const deleteMessage = $((id: number) => {
|
||||
axios.delete(`http://localhost:8000/api/guestbook/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
});
|
||||
messages.value = messages.value.filter((message: any) => message.id !== id);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="bg-opacity-45 z-[1000] fixed inset-0 w-full h-full py-6 px-6 bg-black flex items-center justify-center" onClick$={() => {showDeleteModal.value = false; selectedMessage.value = null}}>
|
||||
<div class="bg-white p-4 rounded-lg flex gap-2 flex-col items-center justify-center" onClick$={(e) => e.stopPropagation()}>
|
||||
<p>삭제하시겠습니까?</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="bg-button-color-1 text-black px-4 py-2 rounded-lg" onclick$={() => {
|
||||
showDeleteModal.value = false;
|
||||
deleteMessage(selectedMessage.value.id)}}>삭제</button>
|
||||
<button class="bg-button-color-3 text-black px-4 py-2 rounded-lg" onclick$={() => {
|
||||
showDeleteModal.value = false;
|
||||
selectedMessage.value = null}}>취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
0
src/components/features/guestbook/UpdateMoadl.tsx
Normal file
0
src/components/features/guestbook/UpdateMoadl.tsx
Normal file
44
src/components/features/shop/BuyModal.tsx
Normal file
44
src/components/features/shop/BuyModal.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { component$, Signal, $ } from "@builder.io/qwik";
|
||||
import axios from "axios";
|
||||
import { getCookie } from "~/utils/cookie";
|
||||
|
||||
export const BuyModal = component$(({ showBuyModal, item, mydotory, itemdotory}: {showBuyModal: Signal<boolean>, item: any, mydotory: number, itemdotory: number}) => {
|
||||
const handleBuy = $(async() => {
|
||||
try {
|
||||
const res = await axios.post(`http://localhost:8000/api/store/${item.id}?product_name=${item.name}`, {
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
})
|
||||
console.log(res.data);
|
||||
showBuyModal.value = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
const handleCancel = $(() => {
|
||||
showBuyModal.value = false;
|
||||
});
|
||||
return (
|
||||
<div class="flex h-full w-full items-center justify-center bg-black bg-opacity-35">
|
||||
<div class="flex flex-col items-center gap-2 rounded-lg bg-white p-10">
|
||||
<p> {item.name} </p>
|
||||
<p class="font-bold text-2xl"> 정말 구매하시겠습니까?</p>
|
||||
|
||||
<p class="text-gray-500">구매 후 잔액: {mydotory - itemdotory}</p>
|
||||
<div class="flex gap-2">
|
||||
<button onclick$={handleBuy} class="bg-diary-color hover:bg-diary-icon-hover rounded-lg p-2">
|
||||
구매하기
|
||||
<i class="bi bi-cart-plus"></i>
|
||||
</button>
|
||||
<button onclick$={handleCancel} class="bg-button-color-2 hover:bg-button-color-2-hover rounded-lg p-2">
|
||||
취소하기
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
});
|
||||
12
src/components/features/shop/ItemCard.tsx
Normal file
12
src/components/features/shop/ItemCard.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { component$ } from "@builder.io/qwik";
|
||||
|
||||
export const ItemCard = component$(({item}: {item: any}) => {
|
||||
return (
|
||||
<div>
|
||||
<img src={`http://localhost:8000/${item.image_path}`} alt={item.name} />
|
||||
<h1>{item.name}</h1>
|
||||
<p>{item.price}</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
0
src/components/features/shop/NotEnoughFundsModa.tsx
Normal file
0
src/components/features/shop/NotEnoughFundsModa.tsx
Normal file
116
src/components/layout/Header.tsx
Normal file
116
src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { $, component$, Signal, useSignal, useVisibleTask$ } from "@builder.io/qwik";
|
||||
import terminate from "~/assets/images/terminate.svg";
|
||||
import { useNavigate, useLocation } from "@builder.io/qwik-city";
|
||||
import { Link } from "@builder.io/qwik-city";
|
||||
import { deleteCookie, getCookie } from "~/utils/cookie";
|
||||
import { AddFriends } from "~/components/features/AddFriends";
|
||||
import axios from "axios";
|
||||
|
||||
export default component$(({ showAddFriends }: { showAddFriends: Signal<boolean> }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const handleLogout = $(() => {
|
||||
console.log("logout");
|
||||
deleteCookie("access_token");
|
||||
navigate("/auth");
|
||||
});
|
||||
const myInfo = useSignal<Friend>({
|
||||
id: 0,
|
||||
username: "",
|
||||
email: "",
|
||||
created_at: "",
|
||||
profile_image_path: "",
|
||||
is_active: false
|
||||
});
|
||||
const visitedUser = useSignal<Friend>({
|
||||
id: 0,
|
||||
username: "",
|
||||
email: "",
|
||||
created_at: "",
|
||||
profile_image_path: "",
|
||||
is_active: false
|
||||
});
|
||||
useVisibleTask$(() => {
|
||||
try {
|
||||
axios.get('http://localhost:8000/api/user/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
}).then((res) => {
|
||||
myInfo.value = res.data;
|
||||
console.log(myInfo.value);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
})
|
||||
axios.get(`http://localhost:8000/api/user/profile/${location.params.username}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
}).then((res) => {
|
||||
console.log(res.data);
|
||||
visitedUser.value = res.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
})
|
||||
const nickname = location.params.username;
|
||||
return (
|
||||
<header class="sticky xl:flex hidden left-0 top-0 min-h-screen w-64 p-4 bg-custom-gradient flex-col pt-16 overflow-y-auto">
|
||||
{visitedUser.value.id !== myInfo.value.id &&
|
||||
<a href={`/${myInfo.value.username}/`} class="text-default w-full text-center underline mb-2">
|
||||
내 페이지로 돌아가기
|
||||
</a>
|
||||
}
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="w-48 h-48 hidden md:block">
|
||||
<input type="image" src={`http://localhost:8000/${visitedUser.value.profile_image_path}`} class=" object-cover w-full h-full bg-white rounded-full" alt="profile" />
|
||||
</div>
|
||||
<p class="text-gray-500 text-sm max-w-64 w-full bg-white pl-2 p-1 rounded-md">{nickname}</p>
|
||||
</div>
|
||||
<h1 class="mt-8 text-2xl font-bold border-t-2 border-black pt-2 pb-6 hidden sm:block">목록</h1>
|
||||
<nav class="">
|
||||
<ul class="space-y-2 text-xl">
|
||||
<li >
|
||||
<Link href={`/${nickname}/`} class="flex items-center p-3 rounded-lg hover:bg-white hover:text-black transition-colors">
|
||||
<i class="bi bi-house-door"></i>
|
||||
<span class="ml-2 hidden sm:block">홈</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={`/${nickname}/diary`} class="flex items-center p-3 rounded-lg hover:bg-white hover:text-black transition-colors">
|
||||
<i class="bi bi-journal-bookmark"></i>
|
||||
<span class="ml-2 hidden sm:block">다이어리</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={`/${nickname}/guestbook`} class="flex items-center p-3 rounded-lg hover:bg-white hover:text-black transition-colors">
|
||||
<i class="bi bi-journals"></i>
|
||||
<span class="ml-2 hidden sm:block">방명록</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li onclick$={() => {showAddFriends.value = true; console.log(showAddFriends.value)}} class={myInfo.value.username === visitedUser.value.username ? "" : "hidden"}>
|
||||
<div class="flex hover:cursor-pointer items-center p-3 rounded-lg hover:bg-white hover:text-black transition-colors">
|
||||
<i class="bi bi-person-add"></i>
|
||||
<span class="ml-2 hidden sm:block">친구 추가</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class={myInfo.value.username === visitedUser.value.username ? "" : "hidden"}>
|
||||
<Link href={`/${nickname}/store`} class="flex items-center p-3 rounded-lg hover:bg-white hover:text-black transition-colors">
|
||||
<i class="bi bi-bag"></i>
|
||||
<span class="ml-2 hidden sm:block">상점</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="mt-auto flex flex-row justify-center items-center gap-2">
|
||||
<p class="text-sm text-center text-black border-b-2 border-black">로그아웃</p>
|
||||
<button class="h-4 w-4" onClick$={handleLogout}><img src={terminate} width={12} height={12} alt="terminate" /></button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
});
|
||||
299
src/components/room/RoomGrid.tsx
Normal file
299
src/components/room/RoomGrid.tsx
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import { $, component$, useSignal, useStylesScoped$, Signal, useVisibleTask$} from "@builder.io/qwik";
|
||||
import { typechecker } from "~/utils/func";
|
||||
|
||||
interface FurnitureItem {
|
||||
name: string;
|
||||
image_path: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export default component$(({ furniture, avatar, roomType }: { furniture: Signal<any>, avatar: Signal<any>, roomType: Signal<string> }) => {
|
||||
useStylesScoped$(STYLES);
|
||||
// 가구 아이템 상태 관리
|
||||
const checkedAvatar = useSignal({
|
||||
avatar_type: "",
|
||||
top_clothe_type: "",
|
||||
bottom_clothe_type: ""
|
||||
});
|
||||
|
||||
useVisibleTask$(({ track }) => {
|
||||
track(() => avatar.value);
|
||||
console.log(avatar.value);
|
||||
checkedAvatar.value = {
|
||||
...avatar.value,
|
||||
avatar_type: typechecker(avatar.value.avatar_type),
|
||||
top_clothe_type: typechecker(avatar.value.top_clothe_type),
|
||||
bottom_clothe_type: typechecker(avatar.value.bottom_clothe_type),
|
||||
};
|
||||
console.log(checkedAvatar.value);
|
||||
})
|
||||
const furnitureItems = useSignal<FurnitureItem[]>([
|
||||
{
|
||||
name: '큰 식물1',
|
||||
image_path: 'public/funiture/큰 식물.png',
|
||||
x: 1,
|
||||
y: 1
|
||||
},
|
||||
{
|
||||
name: '녹색 침대1',
|
||||
image_path: 'public/funiture/녹색 침대-90.png',
|
||||
x: 3,
|
||||
y: 3
|
||||
},
|
||||
{
|
||||
name: '쓰레기통열림1',
|
||||
image_path: 'public/funiture/쓰레기통열림.png',
|
||||
x: 5,
|
||||
y: 5
|
||||
},
|
||||
{
|
||||
name: '어항1',
|
||||
image_path: 'public/funiture/어항-0.png',
|
||||
x: 7,
|
||||
y: 7
|
||||
}
|
||||
]);
|
||||
|
||||
// 현재 드래그 중인 가구
|
||||
const draggedItem = useSignal<FurnitureItem | null>(null);
|
||||
// 드래그 시작 시 호출
|
||||
const handleDragStart = $((item: FurnitureItem, e: Event) => {
|
||||
e.preventDefault();
|
||||
draggedItem.value = item;
|
||||
});
|
||||
|
||||
// 드래그 오버 시 기본 동작 방지
|
||||
const handleDragOver = $((e: Event) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// 셀 클릭 시 호출
|
||||
const handleCellClick = $((x: number, y: number, e: Event) => {
|
||||
e.preventDefault();
|
||||
console.log(`Cell clicked: (${x}, ${y})`);
|
||||
});
|
||||
|
||||
// 드롭 시 호출
|
||||
const handleDrop = $((cellX: number, cellY: number, e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!draggedItem.value) return;
|
||||
|
||||
// 이미 해당 위치에 다른 가구가 있는지 확인
|
||||
const isOccupied = furnitureItems.value.some(
|
||||
item => item.x === cellX && item.y === cellY
|
||||
);
|
||||
|
||||
if (!isOccupied) {
|
||||
// 가구 위치 업데이트
|
||||
furnitureItems.value = furnitureItems.value.map(item =>
|
||||
item.name === draggedItem.value?.name
|
||||
? { ...item, x: cellX, y: cellY }
|
||||
: item
|
||||
);
|
||||
}
|
||||
|
||||
draggedItem.value = null;
|
||||
});
|
||||
|
||||
|
||||
// 10x10 그리드 셀 생성
|
||||
const gridCells = Array.from({ length: 100 }, (_, i) => {
|
||||
const row = Math.floor(i / 10) + 1; // 1-10
|
||||
const col = (i % 10) + 1; // 1-10
|
||||
|
||||
// 해당 위치의 가구 찾기
|
||||
const furniture = furnitureItems.value.find(item => item.x === col && item.y === row);
|
||||
|
||||
return {
|
||||
x: col,
|
||||
y: row,
|
||||
furniture: furniture ? {
|
||||
name: furniture.name,
|
||||
src: furniture.image_path,
|
||||
} : null
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<div class="room-wrapper">
|
||||
<img
|
||||
src={`http://localhost:8000/public/room/${roomType.value}.png`}
|
||||
class="room"
|
||||
alt="Room background"
|
||||
/>
|
||||
|
||||
{/* Grid overlay */}
|
||||
<div class="grid-overlay">
|
||||
{gridCells.map((cell) => (
|
||||
<div
|
||||
key={`${cell.x}-${cell.y}`}
|
||||
class="grid-cell"
|
||||
style={{
|
||||
gridColumn: cell.x,
|
||||
gridRow: cell.y,
|
||||
position: 'relative',
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
// border: '1px dashed rgba(255, 255, 255, 0.1)',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: cell.furniture ? 'rgba(0, 0, 0, 0.2)' : 'transparent'
|
||||
}}
|
||||
onDragOver$={handleDragOver}
|
||||
onDrop$={ e => handleDrop(cell.x, cell.y, e)}
|
||||
onClick$={ e => handleCellClick(cell.x, cell.y, e)}
|
||||
>
|
||||
{cell.furniture && (
|
||||
<img
|
||||
src={`http://localhost:8000/${cell.furniture.src}`}
|
||||
alt={cell.furniture.name}
|
||||
draggable
|
||||
onDragStart$={ e => handleDragStart(furnitureItems.value.find(f => f.name === cell.furniture?.name)!, e)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
imageRendering: 'pixelated',
|
||||
cursor: 'move',
|
||||
zIndex: 10
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<div class="avatar-wrapper">
|
||||
<img
|
||||
src={`http://localhost:8000/${checkedAvatar.value.avatar_type}`}
|
||||
alt={checkedAvatar.value.avatar_type}
|
||||
/>
|
||||
<img
|
||||
src={`http://localhost:8000/${checkedAvatar.value.top_clothe_type}`}
|
||||
alt={checkedAvatar.value.top_clothe_type}
|
||||
/>
|
||||
<img
|
||||
src={`http://localhost:8000/${checkedAvatar.value.bottom_clothe_type}`}
|
||||
alt={checkedAvatar.value.bottom_clothe_type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const STYLES = `
|
||||
.room-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.room {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.grid-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
grid-template-rows: repeat(10, 1fr);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.avatar-wrapper img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 75px;
|
||||
height: 100px;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.room-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 700px;
|
||||
height: 700px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.room {
|
||||
position: absolute;
|
||||
z-index: 1 !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-rendering: pixelated;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 700px;
|
||||
height: 700px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
grid-template-rows: repeat(10, 1fr);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-overlay div {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.grid-overlay > div {
|
||||
position: relative;
|
||||
height: 70px;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.grid-overlay > div img {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
position: absolute;
|
||||
top: 70%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.avatar-wrapper img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
image-rendering: pixelated;
|
||||
z-index: 99;
|
||||
}
|
||||
`;
|
||||
0
src/components/ui/button/Button.tsx
Normal file
0
src/components/ui/button/Button.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
@font-face {
|
||||
font-family: "Wanted Sans";
|
||||
src: url("./assets/fonts/WantedSans-1.0.3/webfonts/static/complete/WantedSans.css");
|
||||
}
|
||||
* {
|
||||
font-family: "Wanted Sans";
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
}
|
||||
html, body {
|
||||
/* height: 100%; */
|
||||
}
|
||||
input:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0px 1000px white inset !important;
|
||||
-webkit-text-fill-color: #000 !important;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { component$, isDev } from "@builder.io/qwik";
|
||||
import { QwikCityProvider, RouterOutlet } from "@builder.io/qwik-city";
|
||||
import { RouterHead } from "./components/router-head/router-head";
|
||||
|
||||
import './tailwind.css';
|
||||
import "./global.css";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
|
||||
export default component$(() => {
|
||||
/**
|
||||
|
|
|
|||
54
src/routes/(auth)/auth/auth.css
Normal file
54
src/routes/(auth)/auth/auth.css
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateX(-10px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slideInRight {
|
||||
animation: slideInRight 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
@apply h-full transition-all duration-500 ease-in-out;
|
||||
}
|
||||
|
||||
.form-initial {
|
||||
@apply w-full max-w-4xl p-8 bg-white lg:rounded-tl-[150px] lg:rounded-br-[150px] rounded-bl-md rounded-tr-md shadow-[0_12px_4px_-2px_rgba(0,0,0,0.3)];
|
||||
}
|
||||
|
||||
.form-expanded {
|
||||
@apply w-full max-w-7xl flex overflow-hidden bg-white lg:rounded-tl-[150px] lg:rounded-br-[150px] rounded-bl-md rounded-tr-md shadow-[0_12px_4px_-2px_rgba(0,0,0,0.3)];
|
||||
}
|
||||
|
||||
.greeting-side {
|
||||
@apply flex-1 p-12 flex flex-col justify-center items-center text-center;
|
||||
}
|
||||
|
||||
.form-side {
|
||||
@apply p-4 flex flex-col justify-center;
|
||||
}
|
||||
|
||||
@media (max-width: 425px) {
|
||||
.form-expanded {
|
||||
@apply flex-col;
|
||||
}
|
||||
|
||||
.greeting-side,
|
||||
.form-side {
|
||||
@apply p-4;
|
||||
}
|
||||
}
|
||||
180
src/routes/(auth)/auth/index.tsx
Normal file
180
src/routes/(auth)/auth/index.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { $, component$, useSignal, useStore, noSerialize , type NoSerialize } from "@builder.io/qwik";
|
||||
import type { DocumentHead } from "@builder.io/qwik-city";
|
||||
import "./auth.css";
|
||||
import axios from "axios";
|
||||
import { useNavigate } from "@builder.io/qwik-city";
|
||||
import { setCookie } from "~/utils/cookie";
|
||||
export default component$(() => {
|
||||
const navigate = useNavigate();
|
||||
const isRegistering = useSignal(false);
|
||||
const userState = useStore({
|
||||
username: '',
|
||||
password: '',
|
||||
error: '',
|
||||
email: '',
|
||||
profile_file: null as NoSerialize<File> | null,
|
||||
});
|
||||
const fileInputRef = useSignal<HTMLInputElement>();
|
||||
|
||||
const handleFileInput = $((event: Event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0] ?? null;
|
||||
userState.profile_file = file ? noSerialize(file) : null;
|
||||
});
|
||||
const handleSubmit = $(async () => {
|
||||
console.log(userState);
|
||||
if (isRegistering.value) {
|
||||
try {
|
||||
console.log('Register start');
|
||||
const formData = new FormData();
|
||||
if (!userState.username.trim()) {
|
||||
userState.error = "이름을 입력해주세요.";
|
||||
return;
|
||||
}
|
||||
if (!userState.password.trim()) {
|
||||
userState.error = "비밀번호를 입력해주세요.";
|
||||
return;
|
||||
}
|
||||
if (!userState.email.trim()) {
|
||||
userState.error = "이메일을 입력해주세요.";
|
||||
return;
|
||||
}
|
||||
formData.append('username', userState.username.trim());
|
||||
formData.append('password', userState.password.trim());
|
||||
formData.append('email', userState.email.trim());
|
||||
if (userState.profile_file) {
|
||||
formData.append('profile_file', userState.profile_file);
|
||||
}
|
||||
console.log(formData);
|
||||
const res = await axios.post('http://localhost:8000/api/user/register', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
console.log(res.data);
|
||||
console.log('Register success');
|
||||
userState.error = '';
|
||||
userState.username = '';
|
||||
userState.password = '';
|
||||
userState.email = '';
|
||||
userState.profile_file = null;
|
||||
isRegistering.value = false;
|
||||
} catch (error : any) {
|
||||
console.error('Register failed:', error);
|
||||
userState.error = error.response?.data?.detail || 'Register failed';
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
console.log('Login start');
|
||||
if (!userState.username.trim()) {
|
||||
userState.error = "이름을 입력해주세요.";
|
||||
return;
|
||||
}
|
||||
if (!userState.password.trim()) {
|
||||
userState.error = "비밀번호를 입력해주세요.";
|
||||
return;
|
||||
}
|
||||
console.log(userState.username.trim(), userState.password.trim());
|
||||
const res = await axios.post('http://localhost:8000/api/user/login', {
|
||||
username: userState.username.trim(),
|
||||
password: userState.password.trim(),
|
||||
})
|
||||
console.log('Login success');
|
||||
userState.username = '';
|
||||
userState.password = '';
|
||||
userState.error = '';
|
||||
userState.email = '';
|
||||
userState.profile_file = null;
|
||||
setCookie('access_token', res.data.access_token);
|
||||
isRegistering.value = false;
|
||||
navigate('/');
|
||||
} catch (error : any) {
|
||||
console.error('Login failed:', error);
|
||||
userState.error = error.response?.data?.detail || 'Login failed';
|
||||
}
|
||||
}
|
||||
});
|
||||
return (
|
||||
<div class="flex items-center w-full h-full justify-center min-h-screen">
|
||||
<div class={`form-container flex h-full min-h-[720px] justify-center ${isRegistering.value ? 'form-expanded' : 'form-initial'} shadow-[0_6px_12px_-2px_rgba(0,0,0, 0.2)]`}>
|
||||
{isRegistering.value ? (
|
||||
// 회원가입
|
||||
<div class="w-full flex flex-col lg:flex-row">
|
||||
<div class="greeting-side">
|
||||
<h1 class="md:text-4xl text-3xl font-bold mb-4">등록하기</h1>
|
||||
<p class="mb-3 text-xs font-bold text-gray-custom opacity-70">나만의 작은 세상, 지금 만들어보세요.</p>
|
||||
<i class="bi bi-people h-auto leading-none bg-clip-text text-transparent bg-signup-icon md:text-[15rem] text-[10rem]"></i>
|
||||
</div>
|
||||
<div class="form-side flex-auto ">
|
||||
<form class="space-y-4" onSubmit$={handleSubmit} preventdefault:submit>
|
||||
<div >
|
||||
<label for="email" class="block text-sm font-medium text-default">이메일 <i class="bi bi-envelope text-input-icon-color"></i></label>
|
||||
<input id="email" onInput$={(e) => userState.email = (e.target as HTMLInputElement).value} type="email" class="w-full p-3 border border-input-border-color rounded-xl" placeholder="이메일 입력" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-default">이름 <i class="bi bi-person text-input-icon-color"></i></label>
|
||||
<input id="username" onInput$={(e) => userState.username = (e.target as HTMLInputElement).value} type="text" class="w-full p-3 border border-input-border-color rounded-xl" placeholder="이름 입력" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-default">비밀번호 <i class="bi bi-lock text-input-icon-color"></i></label>
|
||||
<input id="password" autocomplete="new-password" onInput$={(e) => userState.password = (e.target as HTMLInputElement).value} type="password" class="w-full p-3 border border-input-border-color rounded-xl" placeholder="8~20자 영문 대소문자, 숫자, 특수문자 비밀번호 입력" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="profileImage" class="block text-sm font-medium text-default">프로필 이미지 <i class="bi bi-image text-input-icon-color"></i></label>
|
||||
<input id="profileImage" type="file" ref={fileInputRef} onChange$={handleFileInput} accept="image/*" class="w-full p-3 border border-input-border-color rounded-xl" />
|
||||
|
||||
</div>
|
||||
{/* <div>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-default">비밀번호 확인 <i class="bi bi-lock text-input-icon-color"></i></label>
|
||||
<input id="confirm-password" autocomplete="new-password" onInput$={(e) => userState.password = (e.target as HTMLInputElement).value} type="password" class="w-full p-3 border border-input-border-color rounded-xl" placeholder="비밀번호 다시 입력" />
|
||||
</div> */}
|
||||
<button type="submit" class="w-full bg-button-color-1 h-12 text-default py-3 px-4 rounded-xl hover:bg-button-color-1/1 transition-colors">
|
||||
회원가입
|
||||
</button>
|
||||
{userState.error && <p class="text-center w-full text-red-500 ">{userState.error}</p>}
|
||||
</form>
|
||||
<div class="mt-4 text-center flex flex-row justify-center gap-2">
|
||||
<p> 이미 계정이 있으신가요?</p>
|
||||
<button onClick$={() => {isRegistering.value = false; userState.username = ''; userState.password = ''; userState.email = ''; userState.profile_file = null}} class="text-gray-500 hover:text-gray-500/80 text-sm font-medium">로그인하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 로그인
|
||||
<div class="w-auto flex flex-col justify-center items-center">
|
||||
<h1 class="text-5xl font-bold mb-2 text-center inline-block">Hello, CyWorld!</h1>
|
||||
<p class="mb-2 text-xs font-semibold text-gray-custom opacity-70 text-center inline-block">나만의 작은 세상, 지금 만들어보세요. <i class="bi bi-emoji-smile"></i></p>
|
||||
<div class="form-side w-full inline-block">
|
||||
<form class="space-y-4" onSubmit$={handleSubmit} preventdefault:submit>
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-default text-left">이름 <i class="bi bi-person text-input-icon-color"></i></label>
|
||||
<input id="username" onInput$={(e) => userState.username = (e.target as HTMLInputElement).value} type="text" class="w-full p-3 border border-input-border-color rounded-xl" placeholder="이름 입력" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-default text-left">비밀번호 <i class="bi bi-lock text-input-icon-color"></i></label>
|
||||
<input id="password" autocomplete="new-password" onInput$={(e) => userState.password = (e.target as HTMLInputElement).value} type="password" class="w-full p-3 border border-input-border-color rounded-xl" placeholder="비밀번호 입력" />
|
||||
</div>
|
||||
<button type="submit" class="w-full bg-button-color-1 h-12 text-default py-3 px-4 rounded-xl hover:bg-opacity-80 transition-colors">로그인
|
||||
</button>
|
||||
{userState.error && <p class="text-center w-full text-red-500">{userState.error}</p>}
|
||||
</form>
|
||||
<div class="mt-4 text-center flex flex-row justify-center gap-2">
|
||||
<p class="text-default">계정이 없으신가요?</p>
|
||||
<button onClick$={() => {isRegistering.value = true; userState.username = ''; userState.password = ''; userState.email = ''; userState.profile_file = null}} class="text-gray-500 hover:text-gray-500/80 text-sm font-medium">회원가입하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: "Login / Sign Up",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "Login or create a new account",
|
||||
},
|
||||
],
|
||||
};
|
||||
42
src/routes/(auth)/auth/layout.tsx
Normal file
42
src/routes/(auth)/auth/layout.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { component$, Slot } from "@builder.io/qwik";
|
||||
import type { DocumentHead, RequestHandler } from "@builder.io/qwik-city";
|
||||
import axios from "axios";
|
||||
|
||||
export const onGet: RequestHandler = async ({ cookie, redirect }) => {
|
||||
const jwt = cookie.get('access_token')?.value;
|
||||
console.log(jwt + "이것은 auth로 들어온 token");
|
||||
if (jwt) {
|
||||
try {
|
||||
const res = await axios.get('http://localhost:8000/api/user/profile/쌀숭이', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
});
|
||||
console.log(res.data);
|
||||
console.log(encodeURIComponent(res.data.username));
|
||||
throw redirect(302, `/${encodeURIComponent(res.data.username)}/`);
|
||||
} catch (error : any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default component$(() => {
|
||||
return (
|
||||
<>
|
||||
<main class="bg-custom-gradient w-full h-full">
|
||||
<Slot />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: "Auth",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "Auth page description",
|
||||
},
|
||||
],
|
||||
};
|
||||
14
src/routes/404.tsx
Normal file
14
src/routes/404.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { component$, Slot } from "@builder.io/qwik";
|
||||
|
||||
|
||||
|
||||
export default component$(() => {
|
||||
return (
|
||||
<div class="w-full h-full">
|
||||
404 Not found
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<button class="bg-button-color-1 h-12 text-default py-3 px-4 rounded-xl hover:bg-opacity-80 transition-colors">Home</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
63
src/routes/[username]/(store)/layout.tsx
Normal file
63
src/routes/[username]/(store)/layout.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Link, RequestHandler } from "@builder.io/qwik-city";
|
||||
// import { onRequest } from "../layout";
|
||||
import { component$, Slot } from "@builder.io/qwik";
|
||||
import { DocumentHead } from "@builder.io/qwik-city";
|
||||
import { useLocation } from "@builder.io/qwik-city";
|
||||
import axios from "axios";
|
||||
import { routeLoader$ } from "@builder.io/qwik-city";
|
||||
export const onRequest: RequestHandler = async ({ cookie, params, sharedMap}) => {
|
||||
|
||||
console.log(params.username + "의 상점 페이지로 들어옴");
|
||||
|
||||
try {
|
||||
const res = await axios.get(`http://localhost:8000/api/store`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
});
|
||||
sharedMap.set("dotory", res.data ?? 0);
|
||||
console.log(res.data ?? "no data");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
sharedMap.set("dotory", 0);
|
||||
}
|
||||
}
|
||||
export const useDotory = routeLoader$(({sharedMap}) => {
|
||||
return sharedMap.get("dotory") as number;
|
||||
});
|
||||
export default component$(() => {
|
||||
const location = useLocation();
|
||||
const dotory = useDotory();
|
||||
|
||||
console.log(location.url.pathname);
|
||||
console.log("/"+location.params.username+"/store");
|
||||
return (
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="flex justify-center flex-col gap-4 p-2 items-center w-full">
|
||||
<div class="flex items-center justify-between w-full bg-diary-color rounded-lg p-2">
|
||||
<div class="flex md:flex-row flex-col gap-2">
|
||||
<Link href={`/${location.params.username}/store`} class={`${location.url.pathname === `/${location.params.username}/store` ? "bg-[#FAD659]" : "bg-white"} text-gray-800 px-4 py-2 rounded-lg font-medium`}>상점 <i class="bi bi-shop"></i></Link>
|
||||
<Link href={`/${location.params.username}/room`} class={`${location.url.pathname === `/${location.params.username}/room` ? "bg-[#FAD659]" : "bg-white"} text-gray-800 px-4 py-2 rounded-lg font-medium`}>방</Link>
|
||||
</div>
|
||||
<div class="flex md:flex-row flex-col">
|
||||
{/* <div class="w-12 h-12 bg-[#33231A] [clip-path:polygon(20%_0,100%_0,80%_100%,20%_100%,0_80%,0_20%)]">
|
||||
</div> */}
|
||||
<div class="ml-auto bg-[#CC8A6A] text-default px-4 py-2 rounded-lg font-medium">도토리: {dotory.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Slot />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: "Store",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "Store page description",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
393
src/routes/[username]/(store)/room/index.tsx
Normal file
393
src/routes/[username]/(store)/room/index.tsx
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { $, component$, useSignal, useTask$, useVisibleTask$ } from "@builder.io/qwik";
|
||||
import { DocumentHead } from "@builder.io/qwik-city";
|
||||
import RoomGrid from "~/components/room/RoomGrid";
|
||||
import { typechecker } from "~/utils/func";
|
||||
import { routeLoader$ } from "@builder.io/qwik-city";
|
||||
import axios from "axios";
|
||||
import { getCookie } from "~/utils/cookie";
|
||||
interface Furniture {
|
||||
furniture_name: string;
|
||||
image_path: string;
|
||||
x : number;
|
||||
y : number;
|
||||
}
|
||||
interface Avatar {
|
||||
id: number;
|
||||
user_id: number;
|
||||
avatar_type: {
|
||||
name: string;
|
||||
path: string
|
||||
},
|
||||
top_clothe_type: {
|
||||
name: string;
|
||||
path: string
|
||||
},
|
||||
bottom_clothe_type: {
|
||||
name: string;
|
||||
path: string
|
||||
}
|
||||
}
|
||||
interface SetAvatarType {
|
||||
avatar_type: string;
|
||||
top_clothe_type: string;
|
||||
bottom_clothe_type: string;
|
||||
}
|
||||
interface RoomType {
|
||||
type: string;
|
||||
image_path: string;
|
||||
}
|
||||
|
||||
interface Room {
|
||||
id: number;
|
||||
user_id: number;
|
||||
room_type: string;
|
||||
room_name: string;
|
||||
room_image_path: string;
|
||||
}
|
||||
export const useRoomLoader = routeLoader$(async ({cookie}) => {
|
||||
try {
|
||||
const myRoom = await axios.get("http://localhost:8000/api/room/layout", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
})
|
||||
const myAvatar = await axios.get("http://localhost:8000/api/avatar", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
})
|
||||
const roomType = await axios.get("http://localhost:8000/api/room/types", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
})
|
||||
const furniture = await axios.get("http://localhost:8000/api/room/my", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
})
|
||||
const avatarType = await axios.get("http://localhost:8000/api/avatar/options", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
})
|
||||
const roomTypeData = roomType.data;
|
||||
const avatar : Avatar = myAvatar.data;
|
||||
const room : Room = myRoom.data;
|
||||
const furnitureData = furniture.data;
|
||||
const avatarTypeData = avatarType.data;
|
||||
console.log(myRoom.data);
|
||||
return {myData : {room: room, avatar: avatar}, selectionData: {roomType: roomTypeData, furniture: furnitureData, avatarType: avatarTypeData}};
|
||||
} catch (error : any) {
|
||||
console.error(error);
|
||||
return error.response?.data?.detail || 'Room not found';
|
||||
}
|
||||
})
|
||||
export default component$(() => {
|
||||
const data = useRoomLoader();
|
||||
const selectedFurniture = useSignal<Furniture[]>([]); // 유저 선택 가구
|
||||
const selectedType = useSignal("furniture"); // 헤더에서 가구, 아바타, 배경 구분
|
||||
const selectedRoomType = useSignal("room_1");
|
||||
const selectedAvatar = useSignal<SetAvatarType>({
|
||||
avatar_type: "",
|
||||
top_clothe_type: "",
|
||||
bottom_clothe_type: ""
|
||||
});
|
||||
useVisibleTask$(() => {
|
||||
selectedRoomType.value = data.value.myData.room.room.room_type;
|
||||
selectedAvatar.value.avatar_type = data.value.myData.avatar.avatar_type.name;
|
||||
selectedAvatar.value.top_clothe_type = data.value.myData.avatar.top_clothe_type.name;
|
||||
selectedAvatar.value.bottom_clothe_type = data.value.myData.avatar.bottom_clothe_type.name;
|
||||
|
||||
})
|
||||
const deleteFurniture = $((selectedFurniture : Furniture) => {
|
||||
const furnitureRes = axios.delete("http://localhost:8000/api/room/furniture", {
|
||||
data: {
|
||||
furniture_name: selectedFurniture.furniture_name,
|
||||
x: selectedFurniture.x,
|
||||
y: selectedFurniture.y,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
})
|
||||
})
|
||||
// const getNewState = async () => {
|
||||
// const myRoom = await axios.get("http://localhost:8000/api/room/layout", {
|
||||
// headers: {
|
||||
// Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
// },
|
||||
// })
|
||||
// const myAvatar = await axios.get("http://localhost:8000/api/avatar", {
|
||||
// headers: {
|
||||
// Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
// },
|
||||
// })
|
||||
// const roomType = await axios.get("http://localhost:8000/api/room/types", {
|
||||
// headers: {
|
||||
// Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
// },
|
||||
// })
|
||||
// const furniture = await axios.get("http://localhost:8000/api/room/my", {
|
||||
// headers: {
|
||||
// Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
// },
|
||||
// })
|
||||
// const avatarType = await axios.get("http://localhost:8000/api/avatar/options", {
|
||||
// headers: {
|
||||
// Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
// },
|
||||
// })
|
||||
// const roomTypeData = roomType.data;
|
||||
// const avatar : Avatar = myAvatar.data.avatar;
|
||||
// const room : Room = myRoom.data;
|
||||
// const furnitureData = furniture.data;
|
||||
// const avatarTypeData = avatarType.data;
|
||||
// console.log(myRoom.data);
|
||||
// }
|
||||
const handleChange = $( async () => {
|
||||
console.log("handleChange");
|
||||
try {
|
||||
// 배경 설정
|
||||
console.log(selectedRoomType.value);
|
||||
const roomRes = await axios.patch("http://localhost:8000/api/room/",
|
||||
{
|
||||
type: selectedRoomType.value
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const avatarRes = await axios.put("http://localhost:8000/api/avatar", {
|
||||
|
||||
avatar_type: selectedAvatar.value.avatar_type,
|
||||
top_clothe_type: selectedAvatar.value.top_clothe_type,
|
||||
bottom_clothe_type: selectedAvatar.value.bottom_clothe_type,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
// for (let i = 0; i < selectedFurniture.value.length; i++) {
|
||||
// const furnitureRes = await axios.post("http://localhost:8000/api/room/furniture", {
|
||||
// furniture_name: selectedFurniture.value[i].furniture_name,
|
||||
// x: selectedFurniture.value[i].x,
|
||||
// y: selectedFurniture.value[i].y,
|
||||
// },
|
||||
// {
|
||||
// headers: {
|
||||
// Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
// getNewState();
|
||||
console.log(roomRes.data);
|
||||
console.log(avatarRes.data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
})
|
||||
const handleCancel = $(() => {
|
||||
console.log("handleCancel");
|
||||
})
|
||||
return (
|
||||
<div class="flex flex-col h-full p-4">
|
||||
<h1 class="text-2xl font-bold text-[#4b3c28] mb-6">내 방 꾸미기</h1>
|
||||
<div class="flex flex-col md:flex-row gap-6 flex-1">
|
||||
{/* Room Preview */}
|
||||
<div class="flex-1 bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<RoomGrid furniture={selectedFurniture} avatar={selectedAvatar} roomType={selectedRoomType} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div class="w-full md:w-80 flex flex-col gap-4">
|
||||
{/* Category Tabs */}
|
||||
<div class="flex gap-1 bg-gray-100 p-1 rounded-lg">
|
||||
<button
|
||||
onClick$={() => { selectedType.value = "furniture" }}
|
||||
class={`flex-1 py-2 px-3 text-sm rounded-md font-medium transition-colors ${
|
||||
selectedType.value === "furniture"
|
||||
? "bg-white text-[#4b3c28] shadow-sm"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
가구
|
||||
</button>
|
||||
<button
|
||||
onClick$={() => { selectedType.value = "avatar" }}
|
||||
class={`flex-1 py-2 px-3 text-sm rounded-md font-medium transition-colors ${
|
||||
selectedType.value === "avatar"
|
||||
? "bg-white text-[#4b3c28] shadow-sm"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
아바타
|
||||
</button>
|
||||
<button
|
||||
onClick$={() => { selectedType.value = "background" }}
|
||||
class={`flex-1 py-2 px-3 text-sm rounded-md font-medium transition-colors ${
|
||||
selectedType.value === "background"
|
||||
? "bg-white text-[#4b3c28] shadow-sm"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
배경
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Items Grid */}
|
||||
<div class="flex flex-col h-[600px] bg-white border-2 border-[#FAD659] rounded-lg overflow-hidden">
|
||||
<h3 class="text-lg font-semibold p-4 text-[#4b3c28] border-b border-gray-200">
|
||||
{selectedType.value === "furniture" ? "가구" :
|
||||
selectedType.value === "avatar" ? "아바타" : "배경"} 선택
|
||||
</h3>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
{selectedType.value === "furniture" ? (
|
||||
data.value.selectionData.furniture.map((furniture: Furniture) => (
|
||||
<div key={furniture.furniture_name} class={`flex items-center flex-col gap-2 rounded-t-lg` } onClick$={() => {if(selectedFurniture.value.includes(furniture)) selectedFurniture.value = selectedFurniture.value.filter((item: Furniture) => item !== furniture); else selectedFurniture.value.push(furniture);}}>
|
||||
<img src={`http://localhost:8000/${furniture.image_path}`} alt={furniture.furniture_name} style={{imageRendering: "pixelated"}} class={` object-contain w-24 h-24 ${selectedFurniture.value.includes(furniture) ? "border-[2px] border-[#FAD659]" : ""}`} />
|
||||
<span class="text-center font-bold rounded-b-lg w-24 h-12 bg-[#E5E2B6]">{furniture.furniture_name}</span>
|
||||
</div>
|
||||
)))
|
||||
:
|
||||
selectedType.value === "avatar" ? (
|
||||
<div class="flex flex-col gap-3 ">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 mb-2">아바타 타입</h4>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{data.value.selectionData.avatarType.avatar_types.map((type: string, index: number) => (
|
||||
<div
|
||||
key={`type-${index}`}
|
||||
class={`flex flex-col items-center p-2 rounded-lg hover:bg-gray-100 cursor-pointer ${selectedAvatar.value.avatar_type === type ? "border-[2px] border-[#FAD659]" : "border-2 border-gray-200"}`}
|
||||
onClick$={() => {
|
||||
selectedAvatar.value = {
|
||||
...selectedAvatar.value,
|
||||
avatar_type: type
|
||||
};
|
||||
console.log(selectedAvatar.value.avatar_type)}}
|
||||
>
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-1 overflow-hidden border-2 border-gray-200 hover:border-[#FAD659] transition-colors">
|
||||
<img src={`http://localhost:8000/${typechecker(type)}`} alt="avatar" style={{imageRendering: "pixelated"}} class="object-contain w-16 h-16" />
|
||||
</div>
|
||||
<span class="text-xs text-gray-700">{type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 mb-2">상의</h4>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{data.value.selectionData.avatarType.top_clothe_types.map((clothe: string, index: number) => (
|
||||
<div
|
||||
key={`top-${index}`}
|
||||
class={`flex flex-col items-center p-2 rounded-lg hover:bg-gray-100 cursor-pointer ${selectedAvatar.value.top_clothe_type === clothe ? "border-[2px] border-[#FAD659]" : "border-2 border-gray-200"}`}
|
||||
onClick$={() => {
|
||||
selectedAvatar.value = {
|
||||
...selectedAvatar.value,
|
||||
top_clothe_type: clothe
|
||||
};
|
||||
console.log(selectedAvatar.value.top_clothe_type)}}
|
||||
|
||||
>
|
||||
<div class="w-20 h-20 bg-gray-50 rounded-lg flex items-center justify-center mb-2 overflow-hidden border-2 border-gray-200 hover:border-[#FAD659] transition-colors">
|
||||
<img src={`http://localhost:8000/${typechecker(clothe)}`} alt="avatar" style={{imageRendering: "pixelated"}} class="object-contain w-16 h-16" />
|
||||
</div>
|
||||
<span class="text-xs text-gray-700 text-center">{clothe}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 mb-2">하의</h4>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{data.value.selectionData.avatarType.bottom_clothe_types.map((bottom: string, index: number) => (
|
||||
<div
|
||||
key={`bottom-${index}`}
|
||||
class={`flex flex-col items-center p-2 rounded-lg hover:bg-gray-100 cursor-pointer ${selectedAvatar.value.bottom_clothe_type === bottom ? "border-[2px] border-[#FAD659]" : "border-2 border-gray-200"}`}
|
||||
onClick$={() => {
|
||||
selectedAvatar.value = {
|
||||
...selectedAvatar.value,
|
||||
bottom_clothe_type: bottom
|
||||
};
|
||||
console.log(selectedAvatar.value.bottom_clothe_type)}}
|
||||
>
|
||||
<div class="w-20 h-20 bg-gray-50 rounded-lg flex items-center justify-center mb-2 overflow-hidden border-2 border-gray-200 hover:border-[#FAD659] transition-colors">
|
||||
<img src={`http://localhost:8000/${typechecker(bottom)}`} alt="avatar" style={{imageRendering: "pixelated"}} class="object-contain w-full h-full" />
|
||||
</div>
|
||||
<span class="text-xs text-gray-700 text-center">{bottom}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="space-y-4 w-full">
|
||||
<h4 class="font-medium text-gray-700 mb-2">방 배경 선택</h4>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
{data.value.selectionData.roomType.map((roomType: RoomType, index: number) => (
|
||||
<div
|
||||
key={`room-${index}`}
|
||||
class="flex flex-col w-full items-center p-3 rounded-lg hover:bg-gray-50 cursor-pointer border-2 border-gray-200 hover:border-[#FAD659] transition-colors bg-white shadow-sm hover:shadow-md"
|
||||
onClick$={() => {
|
||||
console.log('Selected room type:', roomType);
|
||||
selectedRoomType.value = roomType.type;
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`http://localhost:8000/${roomType.image_path}`}
|
||||
alt={roomType.type}
|
||||
class="w-full h-20 object-cover rounded mb-2"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
{roomType.type}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 bg-[#FAD659] hover:bg-[#f8ce3a] text-[#4b3c28] font-bold py-2 px-4 rounded-lg transition-colors"
|
||||
onClick$={() => {handleChange() }}
|
||||
>
|
||||
저장하기
|
||||
</button>
|
||||
{/* <button
|
||||
class="flex-1 bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold py-2 px-4 rounded-lg transition-colors"
|
||||
onClick$={() => {handleCancel()}}
|
||||
>
|
||||
취소
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: "Room",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "Room page description",
|
||||
},
|
||||
],
|
||||
};
|
||||
120
src/routes/[username]/(store)/store/index.tsx
Normal file
120
src/routes/[username]/(store)/store/index.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { component$, useSignal } from "@builder.io/qwik";
|
||||
import { DocumentHead, routeLoader$ } from "@builder.io/qwik-city";
|
||||
import axios from "axios";
|
||||
import { BuyModal } from "~/components/features/shop/BuyModal";
|
||||
import RoomGrid from "~/components/room/RoomGrid";
|
||||
|
||||
interface ShopItem {
|
||||
name: string;
|
||||
image_path: string;
|
||||
width: number;
|
||||
// Add other properties that your items might have
|
||||
}
|
||||
interface Avatar {
|
||||
id: number;
|
||||
user_id: number;
|
||||
avatar_type: {
|
||||
name: string;
|
||||
path: string
|
||||
},
|
||||
top_clothe_type: {
|
||||
name: string,
|
||||
path: string
|
||||
},
|
||||
bottom_clothe_type: {
|
||||
name: string,
|
||||
path: string
|
||||
}
|
||||
}
|
||||
interface ShopResponse {
|
||||
storeData: ShopItem[];
|
||||
avatarData: Avatar[]; // You might want to define a more specific type for avatar data
|
||||
dotory: number;
|
||||
}
|
||||
|
||||
export const useShopLoader = routeLoader$(async ({cookie}) => {
|
||||
try {
|
||||
const res = await axios.get(`http://localhost:8000/api/room/catalog`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
});
|
||||
const avatar = await axios.get(`http://localhost:8000/api/avatar`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
});
|
||||
// const dotory = await axios.get(`http://localhost:8000/api/store`, {
|
||||
// headers: {
|
||||
// Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
// },
|
||||
// });
|
||||
console.log(res.data);
|
||||
console.log(avatar.data);
|
||||
return {storeData: res.data, avatarData: avatar.data, dotory: 0};
|
||||
} catch (error : any) {
|
||||
console.error(error);
|
||||
return { storeData: [], avatarData: null, dotory: 0};
|
||||
}
|
||||
});
|
||||
export default component$(() => {
|
||||
const data = useShopLoader() as { value: ShopResponse };
|
||||
const selectedItem = useSignal<ShopItem | null>(null);
|
||||
const showBuyModal = useSignal(false);
|
||||
return (
|
||||
<div class="relative flex p-4 justify-between items-center w-full h-full">
|
||||
{/* 좌측 */}
|
||||
<div class="grid grid-cols-2 overflow-auto h-full max-h-[calc(100vh-100px)] gap-2">
|
||||
{data.value.storeData.slice(0, data.value.storeData.length / 2).map((item: ShopItem) => (
|
||||
<div key={item.name} class={`flex items-center flex-col gap-2 rounded-t-lg` } onClick$={() => {if(selectedItem.value === item) selectedItem.value = null; else selectedItem.value = item;}}>
|
||||
<img src={`http://localhost:8000/${item.image_path}`} alt={item.name} style={{imageRendering: "pixelated"}} class={` object-contain w-24 h-24 ${selectedItem.value === item ? "border-[2px] border-[#FAD659]" : ""}`} />
|
||||
<span class="text-center font-bold rounded-b-lg w-24 h-12 bg-[#E5E2B6]">{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 중앙 빈 박스 */}
|
||||
<div class="w-64 bg-white border-text-default border-[2px] rounded-lg h-80 flex items-center justify-center">
|
||||
{selectedItem.value &&
|
||||
<div class="flex flex-col gap-4 items-center">
|
||||
<span class="font-bold">{selectedItem.value.name}</span>
|
||||
<span>비율: {selectedItem.value.width}</span>
|
||||
<img src={`http://localhost:8000/${selectedItem.value?.image_path}`} alt="avatar" style={{imageRendering: "pixelated"}} class="object-contain w-32 h-32" />
|
||||
<button
|
||||
class="bg-diary-color hover:bg-diary-icon-hover md:w-full text-black px-4 py-2 rounded-3xl font-bold transition-all duration-200 flex items-center gap-2 z-10 relative"
|
||||
onClick$={() => {showBuyModal.value = true}}>
|
||||
구매하기
|
||||
<i class="bi bi-cart-fill text-3xl text-black"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
{!selectedItem.value && <p>아이템을 선택해주세요.</p>}
|
||||
</div>
|
||||
|
||||
{/* 우측 */}
|
||||
<div class="grid grid-cols-2 overflow-auto h-full max-h-[calc(100vh-100px)] gap-2">
|
||||
{data.value.storeData.slice(data.value.storeData.length / 2, data.value.storeData.length).map((item: ShopItem) => (
|
||||
<div key={item.name} class={`flex items-center flex-col gap-2 rounded-t-lg` } onClick$={() => {if(selectedItem.value === item) selectedItem.value = null; else selectedItem.value = item;}}>
|
||||
<img src={`http://localhost:8000/${item.image_path}`} alt={item.name} style={{imageRendering: "pixelated"}} class={` object-contain w-24 h-24 ${selectedItem.value === item ? "border-[2px] border-[#FAD659]" : ""}`} />
|
||||
<span class="text-center font-bold rounded-b-lg w-24 h-12 bg-[#E5E2B6]">{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showBuyModal.value &&
|
||||
<div class="bg-opacity-45 z-[1000] fixed inset-0 w-full h-full bg-black"
|
||||
onclick$={() => {showBuyModal.value = false}}>
|
||||
<BuyModal showBuyModal={showBuyModal} item={selectedItem.value} mydotory={data.value.dotory} itemdotory={80}/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
export const head: DocumentHead = {
|
||||
title: "Shop",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "Shop page description",
|
||||
},
|
||||
],
|
||||
};
|
||||
44
src/routes/[username]/diary/[diary_id]/edit/index.tsx
Normal file
44
src/routes/[username]/diary/[diary_id]/edit/index.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { component$, useSignal } from "@builder.io/qwik";
|
||||
import { DocumentHead , RequestHandler, routeLoader$} from "@builder.io/qwik-city";
|
||||
import { useNavigate } from "@builder.io/qwik-city";
|
||||
import axios from "axios";
|
||||
const diaryLoader = routeLoader$<{ message: string }>(async ({params, cookie, redirect}) => {
|
||||
|
||||
try {
|
||||
console.log(params.username + "의 방명록 가져오기");
|
||||
const myInfo = await axios.get(`http://localhost:8000/api/user/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
});
|
||||
if(myInfo.data.username !== params.username) {
|
||||
return redirect(302, `/diary/${params.diary_id}`);
|
||||
}
|
||||
const res = await axios.get(`http://localhost:8000/api/diary/${params.diary_id}`);
|
||||
console.log(res.data);
|
||||
return res.data;
|
||||
} catch (error : any) {
|
||||
console.error(error);
|
||||
return error.response?.data?.detail || 'Diary not found';
|
||||
}
|
||||
});
|
||||
export default component$(() => {
|
||||
const navigate = useNavigate();
|
||||
const diaryData = diaryLoader();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>일기 수정</h1>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: "일기수정하기",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "유저가 쓴 일기의 상세정보",
|
||||
},
|
||||
],
|
||||
};
|
||||
103
src/routes/[username]/diary/[diary_id]/index.tsx
Normal file
103
src/routes/[username]/diary/[diary_id]/index.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { component$, $, useSignal, useContext } from "@builder.io/qwik";
|
||||
import { DocumentHead, useNavigate, routeLoader$, Link, useLocation } from "@builder.io/qwik-city";
|
||||
import axios from "axios";
|
||||
import { getCookie } from "~/utils/cookie";
|
||||
// import { AppStateContext } from "~/utils/context";
|
||||
|
||||
|
||||
type DiaryResponse = {diary: DiaryEntry, myInfo: number } | { detail: string };
|
||||
|
||||
export const useDiaryLoader = routeLoader$<DiaryResponse>(async ({params, cookie, status}) => {
|
||||
const diary_id = params.diary_id;
|
||||
try {
|
||||
console.log(diary_id);
|
||||
const res = await axios.get(`http://localhost:8000/api/diary/${diary_id }`);
|
||||
const myInfo = await axios.get(`http://localhost:8000/api/user/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
});
|
||||
console.log(myInfo.data.id);
|
||||
console.log(res.data);
|
||||
return {diary: res.data, myInfo: myInfo.data.id};
|
||||
} catch (error : any) {
|
||||
console.error(error);
|
||||
return error.response?.data?.detail || 'Diary not found';
|
||||
}
|
||||
});
|
||||
|
||||
export default component$(() => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const diaryLoader = useDiaryLoader();
|
||||
|
||||
// const AppState = useContext(AppStateContext);
|
||||
const handleDelete = $(() => {
|
||||
if ('diary' in diaryLoader.value && 'id' in diaryLoader.value.diary) {
|
||||
try {
|
||||
axios.delete(`http://localhost:8000/api/diary/${diaryLoader.value.diary.id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error : any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
navigate(`/${location.params.username}/diary`);
|
||||
});
|
||||
|
||||
if ('detail' in diaryLoader.value) {
|
||||
return <div class="p-6">Diary not found or error: {diaryLoader.value.detail}</div>;
|
||||
}
|
||||
|
||||
const { diary, myInfo } = diaryLoader.value;
|
||||
|
||||
return (
|
||||
<div class="p-6">
|
||||
<div class="flex border-b-2 border-text-default justify-between items-center mb-8">
|
||||
<h1 class="text-2xl font-bold">{diary.title}</h1>
|
||||
<div class="flex flex-row justify-between items-center">
|
||||
{diary.user_id === myInfo && (
|
||||
<div class="flex gap-3">
|
||||
<button onClick$={handleDelete} >
|
||||
<i class="bi bi-trash-fill text-3xl text-black"></i>
|
||||
</button>
|
||||
<Link href={`/${location.params.username}/diary/${location.params.diary_id}/edit/`}>
|
||||
<i class="bi bi-send-fill text-3xl text-black"></i>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-4">
|
||||
<div class="flex gap-4 overflow-auto">
|
||||
{diaryLoader.value.diary.images.map((image, index) => (
|
||||
<img key={index} src={`http://localhost:8000/${image}`} alt={`일기 ${index + 1}`} class="max-w-full h-auto max-h-64" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p class="whitespace-pre-wrap ">{diaryLoader.value.diary.content}</p>
|
||||
</div>
|
||||
|
||||
{('detail' in diaryLoader.value) && (
|
||||
<div class="flex border-b-2 border-text-default justify-center items-center w-full h-full">
|
||||
<p>일기가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: "일기",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "유저가 쓴 일기의 상세정보",
|
||||
},
|
||||
],
|
||||
};
|
||||
135
src/routes/[username]/diary/index.tsx
Normal file
135
src/routes/[username]/diary/index.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { component$, useSignal, $, useTask$, useContext } from "@builder.io/qwik";
|
||||
import type { DocumentHead } from "@builder.io/qwik-city";
|
||||
import { useNavigate, useLocation, Link, routeLoader$ } from "@builder.io/qwik-city";
|
||||
import axios from "axios";
|
||||
import { AppStateContext } from "~/utils/context";
|
||||
export const useDiaryLoader = routeLoader$( async ({cookie, params}) => {
|
||||
try {
|
||||
const userid = await axios.get(`http://localhost:8000/api/user/profile/${params.username}`);
|
||||
console.log(userid.data.id + "<- 이건 아이디");
|
||||
const res = await axios.get(`http://localhost:8000/api/diary/w/{user_id}?target_user_id=${userid.data.id}&skip=0&limit=6`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
});
|
||||
console.log(res.data);
|
||||
return {user:userid.data, diaries: res.data};
|
||||
} catch (error : any) {
|
||||
console.error(error);
|
||||
return error.response?.data?.detail || 'Diary not found';
|
||||
}
|
||||
});
|
||||
export default component$(() => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const entryLoader = useDiaryLoader();
|
||||
const entries = useSignal<DiaryEntry[]>([]);
|
||||
const filteredEntries = useSignal<DiaryEntry[]>([]);
|
||||
useTask$(() => {
|
||||
console.log( entryLoader.value.user);
|
||||
entries.value = entryLoader.value.diaries as DiaryEntry[];
|
||||
filteredEntries.value = entries.value;
|
||||
});
|
||||
const userData = useContext(AppStateContext);
|
||||
const searchQuery = useSignal('');
|
||||
const selectedMonth = useSignal(new Date().getMonth());
|
||||
const showMonth = useSignal(false);
|
||||
const handleMonthChange = $(() => {
|
||||
showMonth.value = !showMonth.value;
|
||||
});
|
||||
const handleMonthSelect = $((month : number) => {
|
||||
selectedMonth.value = month;
|
||||
filteredEntries.value = entries.value.filter((entry) => {
|
||||
const entryMonth = new Date(entry.created_at).getMonth() + 1;
|
||||
return entryMonth === selectedMonth.value;
|
||||
});
|
||||
console.log(filteredEntries.value);
|
||||
console.log("<->" + selectedMonth.value);
|
||||
showMonth.value = false;
|
||||
});
|
||||
|
||||
const handleSearch = $(() => {
|
||||
if(searchQuery.value === '') {
|
||||
filteredEntries.value = entries.value;
|
||||
}
|
||||
else {
|
||||
filteredEntries.value = entries.value.filter((entry) => entry.title.includes(searchQuery.value));
|
||||
}
|
||||
console.log(filteredEntries.value);
|
||||
});
|
||||
return (
|
||||
<div class="w-full h-full mx-auto p-8">
|
||||
<div class="flex justify-between items-center md:flex-row flex-col mb-6 relative">
|
||||
<div class="relative">
|
||||
<button onClick$={() => handleMonthChange()} class="bg-diary-color hover:bg-diary-icon-hover md:w-full text-black px-4 py-2 rounded-3xl font-bold transition-all duration-200 flex items-center gap-2 z-10 relative">
|
||||
{selectedMonth.value}월 <i class={`bi ${showMonth.value ? 'bi-caret-up' : 'bi-caret-down'} transition-transform duration-200`}></i>
|
||||
</button>
|
||||
<div class={`absolute top-12 left-0 w-48 bg-white rounded-lg shadow-lg overflow-hidden transition-all duration-300 transform origin-top ${showMonth.value ? 'opacity-100 scale-y-100' : 'opacity-0 scale-y-95 pointer-events-none'}`}>
|
||||
<ul class="max-h-60 overflow-y-auto scrollbar-thin">
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => (
|
||||
<li key={month} onClick$={() => { handleMonthSelect(month); }} class={`px-4 py-2 hover:bg-gray-100 cursor-pointer transition-colors ${ month === selectedMonth.value ? 'bg-diary-color/30 font-bold' : '' }`}>
|
||||
{month}월
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex lg:flex-row flex-col gap-2 align-center justify-center">
|
||||
{/* <i class="bi bi-sort-down h-4 w-4 text-center"></i> */}
|
||||
<div class=" bg-diary-icon-hover rounded-3xl">
|
||||
<form class="flex items-center gap-2" preventdefault:submit onSubmit$={handleSearch}>
|
||||
<input type="text" id="search" class="px-4 py-2 rounded-3xl bg-diary-color/30 focus:outline-none" bind:value={searchQuery}/>
|
||||
<button type="submit" class="text-black px-4 py-2 rounded-3xl font-bold transition-colors flex items-center gap-2">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{entryLoader.value.user.username === userData.username && (
|
||||
<button onClick$={() => navigate(`/${location.params.username}/diary/write`)} class="bg-diary-color hover:bg-diary-icon-hover text-black px-4 py-2 rounded-3xl font-bold transition-colors flex items-center gap-2">
|
||||
글쓰기 <i class="bi bi-pencil-fill"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg border-t-2 border-b-2 border-text-default overflow-hidden">
|
||||
<div class="divide-y divide-text-default">
|
||||
<div class="px-6 py-2 bg-diary-color transition-colors cursor-pointer">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base flex-1 text-center whitespace-nowrap overflow-hidden text-ellipsis text-black font-bold text-default">제목</span>
|
||||
<span class="text-sm flex-1 text-end whitespace-nowrap overflow-hidden text-ellipsis text-black font-bold text-default">날짜</span>
|
||||
</div>
|
||||
</div>
|
||||
{ filteredEntries.value.map((entry, index) => (
|
||||
<div key={index} class="px-6 py-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<Link href={`/${location.params.username}/diary/${entry.id}`} class="flex items-center justify-between">
|
||||
<span class="text-base flex-1 text-center whitespace-nowrap overflow-hidden text-ellipsis font-medium text-default">{entry.title}</span>
|
||||
<span class="text-sm flex-1 text-end whitespace-nowrap overflow-hidden text-ellipsis font-medium text-default">{new Date(entry.created_at).toLocaleString()}</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
{Array.from({length: 15 - filteredEntries.value.length}, (_, index) => (
|
||||
<div key={index+filteredEntries.value.length} class="px-6 py-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
{/* <Link href={`/${location.params.username}/diary/`} class="flex items-center justify-between"> */}
|
||||
<span class="text-base flex-1 text-center whitespace-nowrap overflow-hidden text-ellipsis font-medium text-default"> </span>
|
||||
<span class="text-sm flex-1 text-end whitespace-nowrap overflow-hidden text-ellipsis font-medium text-default"> </span>
|
||||
{/* </Link> */}
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: "나의 일기장",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "나만의 일기장. 매일의 추억을 기록해보세요.",
|
||||
},
|
||||
],
|
||||
};
|
||||
250
src/routes/[username]/diary/write/index.tsx
Normal file
250
src/routes/[username]/diary/write/index.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { $, component$, useSignal, useVisibleTask$, NoSerialize } from "@builder.io/qwik";
|
||||
import { useLocation, useNavigate } from "@builder.io/qwik-city";
|
||||
import axios from "axios";
|
||||
import { noSerialize } from "@builder.io/qwik";
|
||||
// import { PhotoUploadModal } from "~/components/features/diary/UploadImages";
|
||||
import { getCookie } from "~/utils/cookie";
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
lastModified: number;
|
||||
url: string;
|
||||
}
|
||||
export default component$(() => {
|
||||
const content = useSignal('');
|
||||
const title = useSignal('');
|
||||
const category = useSignal('');
|
||||
const selectedImages = useSignal<File[]>([]);
|
||||
const textareaRef = useSignal<HTMLTextAreaElement>();
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const showPhotoUploadModal = useSignal(false);
|
||||
const fileInfo = useSignal<FileInfo | null>(null);
|
||||
const fileRef = useSignal<NoSerialize<File> | null>(null);
|
||||
const error = useSignal('');
|
||||
// const fileInputRef = useSignal<HTMLInputElement>();
|
||||
|
||||
const handleFileSelect = $(async (selectedFile: File) => {
|
||||
if (selectedFile && selectedFile.type.startsWith('image/')) {
|
||||
fileInfo.value = {
|
||||
name: selectedFile.name,
|
||||
size: selectedFile.size,
|
||||
type: selectedFile.type,
|
||||
lastModified: selectedFile.lastModified,
|
||||
url: URL.createObjectURL(selectedFile)
|
||||
};
|
||||
fileRef.value = noSerialize(selectedFile);
|
||||
error.value = '';
|
||||
console.log(fileInfo.value);
|
||||
} else {
|
||||
error.value = '이미지 파일만 업로드 가능합니다.';
|
||||
fileInfo.value = null;
|
||||
fileRef.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const handleRemoveImage = $((index: number) => {
|
||||
selectedImages.value = selectedImages.value.filter((_, i) => i !== index);
|
||||
});
|
||||
|
||||
const handleSend = $(async () => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('title', title.value);
|
||||
formData.append('content', content.value);
|
||||
formData.append('category', category.value);
|
||||
|
||||
// Append each image file
|
||||
selectedImages.value.forEach((file) => {
|
||||
formData.append('file', file);
|
||||
});
|
||||
|
||||
await axios.post('http://localhost:8000/api/diary', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
nav(`/${location.params.username}/diary`);
|
||||
} catch (error: any) {
|
||||
console.error('Error creating diary:', error);
|
||||
}
|
||||
});
|
||||
useVisibleTask$(({ track }) => {
|
||||
track(() => content.value);
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.style.height = 'auto';
|
||||
textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-2xl font-bold">일기 쓰기</h1>
|
||||
</div>
|
||||
<div class="border-b-2 border-text-default flex flex-row justify-between items-center">
|
||||
<input
|
||||
bind:value={title}
|
||||
type="text"
|
||||
placeholder="제목을 입력해주세요."
|
||||
class="w-full text-2xl font-bold p-2 focus:outline-none"
|
||||
/>
|
||||
<div class="flex gap-3">
|
||||
<button onClick$={() => nav(`/${location.params.username}/diary`)}>
|
||||
<i class="bi bi-trash-fill text-3xl text-black"></i>
|
||||
</button>
|
||||
<button onClick$={() => handleSend()}>
|
||||
<i class="bi bi-send-fill text-3xl text-black"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image upload and preview section */}
|
||||
<div class="p-4">
|
||||
<div class="flex gap-2 overflow-x-auto py-2">
|
||||
{selectedImages.value.map((image, index) => (
|
||||
<div key={index} class="relative">
|
||||
<img
|
||||
src={URL.createObjectURL(image)}
|
||||
alt={`Preview ${index}`}
|
||||
class="h-24 w-24 object-cover rounded"
|
||||
/>
|
||||
<button
|
||||
onClick$={() => handleRemoveImage(index)}
|
||||
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick$={() => showPhotoUploadModal.value = true}
|
||||
>
|
||||
<i class="bi bi-image-fill text-black text-2xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
bind:value={content}
|
||||
placeholder="본문을 입력해주세요"
|
||||
class="w-full min-h-[70vh] p-4 bg-white border border-text-default rounded-lg focus:outline-none resize-none leading-relaxed whitespace-pre-wrap"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(#f1f1f1 1px, transparent 1px)',
|
||||
backgroundSize: '100% 1.5rem',
|
||||
lineHeight: '1.5rem',
|
||||
backgroundAttachment: 'local',
|
||||
backgroundPositionY: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div >
|
||||
{selectedImages.value.map((file, index) => (
|
||||
<div key={index}>
|
||||
<img src={URL.createObjectURL(file)} alt={`Preview ${index}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showPhotoUploadModal.value &&
|
||||
<div class="fixed inset-0 bg-black bg-opacity-45 flex items-center justify-center z-50" onClick$={() => showPhotoUploadModal.value = false}>
|
||||
<div class="bg-white rounded-lg w-[400px] p-6 shadow-xl relative" onClick$={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
class="absolute top-4 right-4 text-xl text-black"
|
||||
onClick$={() => showPhotoUploadModal.value = false}
|
||||
aria-label="닫기"
|
||||
>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
|
||||
<h2 class="text-3xl font-bold text-center text-black">사진 업로드</h2>
|
||||
<p class="text-xs p-2 text-gray-500 text-center border-b-2 border-black mb-6">
|
||||
소중한 순간들을 기록하고 공유하세요
|
||||
</p>
|
||||
|
||||
<div
|
||||
class={`border-2 border-dashed rounded-md h-32 flex flex-col items-center justify-center text-sm cursor-pointer transition-colors ${
|
||||
fileInfo.value
|
||||
? 'border-green-300 bg-green-50 text-green-700'
|
||||
: 'border-gray-300 bg-gray-100 text-gray-500 hover:border-blue-400'
|
||||
}`}
|
||||
onDragOver$={(e) => e.preventDefault()}
|
||||
onDrop$={async (e) => {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
await handleFileSelect(files[0]);
|
||||
}
|
||||
}}
|
||||
onClick$={async () => {
|
||||
if (fileRef.value) {
|
||||
try {
|
||||
|
||||
console.log('업로드됨:', {
|
||||
file: fileRef.value,
|
||||
fileInfo: fileInfo.value
|
||||
});
|
||||
|
||||
// 업로드 후 초기화
|
||||
fileInfo.value = null;
|
||||
fileRef.value = null;
|
||||
showPhotoUploadModal.value = false;
|
||||
} catch (err) {
|
||||
console.error('업로드 실패:', err);
|
||||
error.value = '파일 업로드 중 오류가 발생했습니다.';
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{fileInfo.value ? (
|
||||
<div class="text-center p-4">
|
||||
<i class="bi bi-check-circle-fill text-2xl mb-1"></i>
|
||||
<p class="font-medium">{fileInfo.value.name}</p>
|
||||
<p class="text-xs">{(fileInfo.value.size / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="text-center p-4">
|
||||
<i class="bi bi-image-fill text-2xl mb-1"></i>
|
||||
<p>클릭 또는 사진을 드래그하세요</p>
|
||||
<p class="text-xs mt-1">JPG, PNG, GIF</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error.value && (
|
||||
<p class="text-red-500 text-sm mt-2">{error.value}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
class="mt-6 w-full bg-[#FFAD84] hover:bg-[#ff9e6b] text-[#4b3c28] font-semibold py-2 rounded-md flex justify-center items-center gap-1 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!fileInfo.value}
|
||||
onClick$={async () => {
|
||||
if (fileRef.value) {
|
||||
try {
|
||||
console.log('Uploading:', {
|
||||
file: fileRef.value,
|
||||
fileInfo: fileInfo.value
|
||||
});
|
||||
|
||||
selectedImages.value = [...selectedImages.value, fileRef.value];
|
||||
fileInfo.value = null;
|
||||
fileRef.value = null;
|
||||
} catch (err) {
|
||||
console.error('업로드 실패:', err);
|
||||
error.value = '파일 업로드 중 오류가 발생했습니다.';
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
업로드 <i class="bi bi-cloud-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
211
src/routes/[username]/guestbook/index.tsx
Normal file
211
src/routes/[username]/guestbook/index.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { component$, useSignal, $, useTask$} from "@builder.io/qwik";
|
||||
import type { DocumentHead } from "@builder.io/qwik-city";
|
||||
import { routeLoader$, useLocation } from "@builder.io/qwik-city";
|
||||
import { getCookie } from "~/utils/cookie";
|
||||
import axios from "axios";
|
||||
import DeleteModal from "~/components/features/guestbook/DeleteModal";
|
||||
interface GuestbookContent{
|
||||
id: number,
|
||||
content: string,
|
||||
target_user_id: number,
|
||||
user_id: number,
|
||||
user_profile_path: string,
|
||||
username: string,
|
||||
created_at: string
|
||||
}
|
||||
interface GuestbookResponse {
|
||||
messages: GuestbookContent[];
|
||||
myInfo: {
|
||||
id: number,
|
||||
username: string,
|
||||
email: string,
|
||||
created_at: string,
|
||||
profile_image_path: string,
|
||||
is_active: boolean
|
||||
};
|
||||
targetUser: {
|
||||
id: number,
|
||||
username: string,
|
||||
email: string,
|
||||
created_at: string,
|
||||
profile_image_path: string,
|
||||
is_active: boolean
|
||||
};
|
||||
}
|
||||
|
||||
export const useGuestbookLoader = routeLoader$(async ({params, cookie}) => {
|
||||
try {
|
||||
console.log(params.username + "의 방명록 가져오기");
|
||||
const userid = await axios.get(`http://localhost:8000/api/user/profile/${params.username}`);
|
||||
const myid = await axios.get(`http://localhost:8000/api/user/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
});
|
||||
const res = await axios.get<GuestbookContent[]>(`http://localhost:8000/api/guestbook/${userid.data.id}`);
|
||||
console.log(res.data);
|
||||
|
||||
return {
|
||||
messages: res.data,
|
||||
myInfo: myid.data,
|
||||
targetUser: userid.data,
|
||||
} as GuestbookResponse;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
messages: [],
|
||||
myInfo: { id: 0, username: 'Guest' ,email: 'Guest', created_at: 'Guest', profile_image_path: 'Guest', is_active: false},
|
||||
targetUser: { id: 0, username: 'Guest' ,email: 'Guest', created_at: 'Guest', profile_image_path: 'Guest', is_active: false}
|
||||
} as GuestbookResponse;
|
||||
}
|
||||
});
|
||||
export default component$(() => {
|
||||
const location = useLocation();
|
||||
const username = location.params.username;
|
||||
const loaderData = useGuestbookLoader();
|
||||
const messages = useSignal<GuestbookContent[]>([]);
|
||||
const newMessage = useSignal('');
|
||||
const showUpdateModal = useSignal(false);
|
||||
const showDeleteModal = useSignal(false);
|
||||
const selectedMessage = useSignal<GuestbookContent>({} as GuestbookContent);
|
||||
const updatedMessage = useSignal('');
|
||||
const refreshMessages = $(async () => {
|
||||
const res = await axios.get<GuestbookContent[]>(`http://localhost:8000/api/guestbook/${loaderData.value.targetUser.id}`);
|
||||
messages.value = res.data;
|
||||
});
|
||||
useTask$(() => {
|
||||
console.log("useTask");
|
||||
refreshMessages();
|
||||
messages.value = loaderData.value.messages;
|
||||
});
|
||||
const handleSubmit = $(() => {
|
||||
if (!newMessage.value.trim()) return;
|
||||
try {
|
||||
axios.post(`http://localhost:8000/api/guestbook/`, {
|
||||
content: newMessage.value,
|
||||
target_user_id: loaderData.value.targetUser.id,
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
});
|
||||
console.log("방명록 추가");
|
||||
refreshMessages();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
newMessage.value = '';
|
||||
});
|
||||
const handleEdit = $(() => {
|
||||
try {
|
||||
axios.put(`http://localhost:8000/api/guestbook/${selectedMessage.value.id}`, {
|
||||
content: updatedMessage.value,
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
refreshMessages();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
showUpdateModal.value = false;
|
||||
updatedMessage.value = '';
|
||||
});
|
||||
return (
|
||||
<div class="flex flex-col h-screen bg-gray-50">
|
||||
{showDeleteModal.value && <DeleteModal messages={messages} showDeleteModal={showDeleteModal} selectedMessage={selectedMessage}/>}
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4 divide-y-2 divide-gray-200">
|
||||
{messages.value.map((msg) => (
|
||||
<div key={msg.id} class="flex relative items-start pt-2 space-x-3">
|
||||
<div class={`w-14 h-14 rounded-full text-black`}>
|
||||
<img class="w-full h-full object-cover rounded-full" src={`http://localhost:8000/${msg.user_profile_path}`} alt="" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm text-gray-500">
|
||||
<span class=" flex items-center gap-2"><span class="text-xl font-bold text-black">{msg.username}</span> <span class="text-xs font-medium text-gray-400">{new Date(msg.created_at).toLocaleString("ko-KR", { timeZone: "Asia/Seoul", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, })}</span></span>
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap text-gray-800">
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
{msg.user_id === loaderData.value.myInfo.id &&
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
onClick$={() => {
|
||||
selectedMessage.value = msg;
|
||||
updatedMessage.value = msg.content;
|
||||
showUpdateModal.value = true;
|
||||
}}
|
||||
class="text-gray-400 hover:text-blue-500 px-2 py-1 text-sm"
|
||||
>
|
||||
<i class="bi bi-pencil-fill"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick$={() => {
|
||||
selectedMessage.value = msg;
|
||||
showDeleteModal.value = true;
|
||||
}}
|
||||
class="text-gray-400 hover:text-red-500 px-2 py-1 text-sm"
|
||||
>
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
{showUpdateModal.value && msg.id === selectedMessage.value.id && (
|
||||
<div
|
||||
class="absolute z-50 bg-white right-0 top-0 p-2 rounded-xl focus:outline-none w-full min-w-[220px]"
|
||||
onClick$={(e) => e.stopPropagation()} /* 외부 클릭으로 닫히는 건 부모가 처리하게 둘 수 있음 */
|
||||
>
|
||||
<div class="flex mt-2 justify-between">
|
||||
<p>수정할 내용을 입력해주세요.</p>
|
||||
<div class="flex gap-2 mt-2 justify-end">
|
||||
<button class="btn-cancel text-button-color-1 border-black" onClick$={() => showUpdateModal.value = false}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
<button class="btn-save text-diary-color border-black" onClick$={handleEdit}>
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
value={updatedMessage.value}
|
||||
onInput$={(ev: any) => (updatedMessage.value = ev.target.value)}
|
||||
class="w-full p-2 border border-input-border-color rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="p-4 bg-white">
|
||||
<form preventdefault:submit onSubmit$={handleSubmit} class=" relative flex">
|
||||
<input
|
||||
bind:value={newMessage}
|
||||
type="text"
|
||||
placeholder="방명록을 입력해 주세요."
|
||||
class="flex-1 p-2 rounded-lg border bg-input-color text-white placeholder:text-white border-gray-300 focus:outline-none focus:ring-1 focus:ring-[#4b3c28]"
|
||||
/>
|
||||
|
||||
{/* <button class="absolute right-12 w-6 h-6 top-1/2 rounded-full bg-gray-500 p-0.5 transform -translate-x-1/2 -translate-y-1/2 text-center">
|
||||
<i class="bi bi-images text-white text-center"></i>
|
||||
</button> */}
|
||||
<button onclick$={handleSubmit} class="absolute right-4 w-6 h-6 top-1/2 rounded-full bg-gray-500 p-0.5 transform -translate-x-1/2 -translate-y-1/2 text-center">
|
||||
<i class="bi bi-send text-white text-center"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: "방명록",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "방명록 페이지입니다. 자유롭게 메시지를 남겨보세요!",
|
||||
},
|
||||
],
|
||||
};
|
||||
161
src/routes/[username]/index.tsx
Normal file
161
src/routes/[username]/index.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { component$, useContext, useVisibleTask$ } from "@builder.io/qwik";
|
||||
import { type DocumentHead, routeLoader$, Link, useLocation } from "@builder.io/qwik-city";
|
||||
import axios from "axios";
|
||||
import RoomGrid from "~/components/room/RoomGrid";
|
||||
import { useContextProvider, useSignal, $ } from "@builder.io/qwik";
|
||||
import { AppStateContext } from "~/utils/context";
|
||||
import { getCookie } from "~/utils/cookie";
|
||||
interface FriendRequest {
|
||||
id: number;
|
||||
user_id: number;
|
||||
friend_id: number;
|
||||
friend_username: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const useDataLoader = routeLoader$(async ({params, cookie}) => {
|
||||
try {
|
||||
// console.log(cookie.get("access_token")?.value + "이것은 토큰");
|
||||
const userid = await axios.get(`http://localhost:8000/api/user/profile/${params.username}`);
|
||||
// console.log(userid.data.id + "<- 이것은 아이디");
|
||||
const res = await axios.get(`http://localhost:8000/api/diary/w/{userid.data.id}?target_user_id=${userid.data.id}&skip=0&limit=6`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
});
|
||||
const friendRequest = await axios.get(`http://localhost:8000/api/friendship/pending`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
});
|
||||
const room = await axios.get(`http://localhost:8000/api/room/layout`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
});
|
||||
const avatar = await axios.get(`http://localhost:8000/api/avatar`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.get("access_token")?.value}`,
|
||||
},
|
||||
});
|
||||
// console.log(res.data+ "다이어리 데이터 불러오기");
|
||||
// console.log("??/" + res.data.diaries);
|
||||
return {diaries: res.data, friendRequest: friendRequest.data as FriendRequest[], userdata: {room: room.data, avatar: avatar.data} };
|
||||
} catch (error : any) {
|
||||
console.error(error);
|
||||
return {diaries: [], friendRequest: [] as FriendRequest[], userdata: {room: [], avatar: [] } };
|
||||
}
|
||||
});
|
||||
export default component$(() => {
|
||||
const data = useDataLoader();
|
||||
const showMyFriendsRequest = useSignal<boolean>(false);
|
||||
const userData = useContext(AppStateContext);
|
||||
const selectedRoom = useSignal({});
|
||||
const location = useLocation();
|
||||
useVisibleTask$(() => {
|
||||
selectedRoom.value = data.value.userdata.room;
|
||||
// userData.avatar = data.value.userdata.avatar;
|
||||
})
|
||||
const handleAcceptFriend = $( async (friendship_id : number) => {
|
||||
try {
|
||||
console.log(`http://localhost:8000/api/friendship/${friendship_id}/accept`);
|
||||
const res = await axios.put(`http://localhost:8000/api/friendship/${friendship_id}/accept`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${getCookie("access_token")}`,
|
||||
},
|
||||
})
|
||||
data.value.friendRequest = data.value.friendRequest.filter((friend) => friend.id !== friendship_id);
|
||||
|
||||
} catch (error : any) {
|
||||
console.log(error.response?.data?.detail);
|
||||
}
|
||||
})
|
||||
return (
|
||||
<div class="min-h-screen bg-white px-8 py-4 text-[#4b3c28]">
|
||||
<header class="flex justify-between items-start border-b border-[#4b3c28] pb-1 mb-4">
|
||||
<h1 class="text-3xl font-bold">Title</h1>
|
||||
<a href={`/${userData.username}`} class="text-sm underline">내 페이지</a>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<div class="flex-1">
|
||||
<section class="mb-6">
|
||||
<div class="flex justify-between">
|
||||
<h2 class="text-xl font-bold mb-2">Mini Room</h2>
|
||||
<Link href="/miniroom" class="text-base text-[#4b3c28] font-semibold">
|
||||
<i class="bi bi-caret-right"></i> 미니룸 이동
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
{/* <RoomGrid furniture={data.value.room} avatar={data.value.avatar} roomType={data.value.roomType} /> */}
|
||||
<section class="mb-6 flex flex-col justify-between gap-8">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl w-full pl-3 pb-1 font-bold border-b-2 border-[#4b3c28] inline-block mb-2">Update</h2>
|
||||
<ul class="space-y-2">
|
||||
{data.value.diaries.map((diary : any) => (
|
||||
<li key={diary.id} class="flex items-center gap-2 md:text-sm">
|
||||
<Link href={`/${location.params.username}/diary/${diary.id}`} class="flex items-center gap-2 md:text-sm">
|
||||
<span class="bg-[#4b3c28] text-white text-xs w-12 px-2 rounded">게시</span>
|
||||
<span class="text-xs lg:text-sm">{diary.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex-1 pt-7 justify-start w-full lg:w-[220px] text-base lg:mt-0">
|
||||
<div class="border-t border-dashed border-[#4b3c28] pt-2 pb-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="flex-1">다이어리 {data.value.diaries.length}개</span>
|
||||
{userData.username === location.params.username && <span onclick$={() => {showMyFriendsRequest.value = true}} class="flex-1 hover:text-[#4b3c28] cursor-pointer hover:underline">친구 요청 {data.value.friendRequest.length}개</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-b border-dashed border-[#4b3c28] pt-2 pb-2">
|
||||
<span class="flex-1">방명록 4개</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{showMyFriendsRequest.value && (
|
||||
<div onclick$={() => {showMyFriendsRequest.value = false}} class="bg-opacity-45 z-[1000] fixed inset-0 w-full h-full py-6 px-6 flex justify-center items-center bg-black">
|
||||
<div onclick$={(e) => {e.stopPropagation()}} class="flex w-full h-full flex-col max-w-[1000px] p-2 max-h-[400px] bg-white">
|
||||
|
||||
<div class="w-full flex justify-between"><p class="text-lg font-bold p-2 w-full text-left">친구 요청</p>
|
||||
<i class="bi bi-x-lg cursor-pointer" onclick$={() => {showMyFriendsRequest.value = false}}></i></div>
|
||||
{data.value.friendRequest.length > 0 ? (
|
||||
<div class="divide-y-2 divide-gray-200 max-h-64 w-full overflow-y-auto">
|
||||
{data.value.friendRequest.map((friend, i) => (
|
||||
<div key={i} class="flex items-center justify-between border-b-2 border-gray-200 py-3">
|
||||
<div class="flex items-center gap-3"><span class="font-medium text-sm">{friend.friend_username}</span></div>
|
||||
<button class="text-black hover:text-red-500" onclick$={() => {handleAcceptFriend(friend.id)}}><i class="bi bi-check"></i></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<p class="text-gray-500">친구 요청이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: "내 미니홈피",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "나만의 공간에 오신 것을 환영합니다!",
|
||||
},
|
||||
],
|
||||
};
|
||||
72
src/routes/[username]/layout.tsx
Normal file
72
src/routes/[username]/layout.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { component$, Slot, useServerData, useSignal } from "@builder.io/qwik";
|
||||
import { type DocumentHead, RequestHandler, routeLoader$ } from "@builder.io/qwik-city";
|
||||
import Header from "~/components/layout/Header";
|
||||
import { AddFriends } from "~/components/features/AddFriends";
|
||||
import axios from "axios";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { useContextProvider } from "@builder.io/qwik";
|
||||
import { AppStateContext } from "~/utils/context";
|
||||
import { deleteCookie } from "~/utils/cookie";
|
||||
export const onRequest: RequestHandler = async ({ cookie, sharedMap, redirect , params}) => {
|
||||
const token = cookie.get("access_token")?.value;
|
||||
console.log(token + "이것은 유저페이지로 들어온 token");
|
||||
if (!token) {
|
||||
console.log("no token");
|
||||
throw redirect(302, "/auth");
|
||||
}
|
||||
try {
|
||||
// jwt.verify(token, process.env.JWT_SECRET!);
|
||||
const userInfo = await axios.get(`http://localhost:8000/api/user/profile/${params.username}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
console.log("data : " + userInfo.data);
|
||||
console.log("token verified");
|
||||
|
||||
const res = await axios.get('http://localhost:8000/api/user/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
sharedMap.set("user", res.data);
|
||||
|
||||
} catch (e : any) {
|
||||
if(e.response.status === 404) {
|
||||
console.log("user not found");
|
||||
throw redirect(302, "/");
|
||||
}
|
||||
else {
|
||||
console.log("token not verified");
|
||||
cookie.delete("access_token");
|
||||
throw redirect(302, "/auth");
|
||||
}
|
||||
}
|
||||
};
|
||||
export const useUser = routeLoader$(({sharedMap}) => {
|
||||
return sharedMap.get("user");
|
||||
});
|
||||
export default component$(() => {
|
||||
const addFriends = useSignal<boolean>(false);
|
||||
const userData = useUser();
|
||||
useContextProvider(AppStateContext, userData.value);
|
||||
return (
|
||||
<div class="flex min-h-screen">
|
||||
<Header showAddFriends={addFriends} />
|
||||
{addFriends.value == true ? <AddFriends showAddFriends={addFriends} /> : null}
|
||||
<main class="flex-1 min-h-screen">
|
||||
<Slot />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: "2025 AnA SSF Pages",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "2025 AnA SSF Pages with Qwik",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -1,25 +1,34 @@
|
|||
import { routeLoader$ } from "@builder.io/qwik-city";
|
||||
import { component$ } from "@builder.io/qwik";
|
||||
import type { DocumentHead } from "@builder.io/qwik-city";
|
||||
|
||||
export default component$(() => {
|
||||
return (
|
||||
<>
|
||||
<h1>Hi 👋</h1>
|
||||
<div>
|
||||
Can't wait to see what you build with qwik!
|
||||
<br />
|
||||
Happy coding.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: "Welcome to Qwik",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "Qwik site description",
|
||||
},
|
||||
],
|
||||
};
|
||||
import axios from "axios";
|
||||
export const useRedirectLoader = routeLoader$(async ({ cookie, redirect }) => {
|
||||
const token = cookie.get("access_token")?.value;
|
||||
console.log(token + "메인으로가면 생기는 토큰");
|
||||
let res;
|
||||
if (token) {
|
||||
// 쿠키에서 username 추출 (JWT decode 하거나 DB 조회 가능)
|
||||
try {
|
||||
res = await axios.get('http://localhost:8000/api/user/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
console.log(res.data.username + "데이터 받아옴");
|
||||
} catch (error : any) {
|
||||
console.error(error + "에러");
|
||||
cookie.delete("access_token");
|
||||
throw redirect(302, "/auth");
|
||||
}
|
||||
} else {
|
||||
console.log("no token");
|
||||
throw redirect(302, "/auth");
|
||||
}
|
||||
if (res.data.username) {
|
||||
throw redirect(302, `/${encodeURIComponent(res.data.username)}/`);
|
||||
}
|
||||
});
|
||||
|
||||
export default component$(() => {
|
||||
// 여긴 사실 실행 안 됨 (위에서 리다이렉트됨)
|
||||
return <div>Loading...</div>;
|
||||
});
|
||||
8
src/tailwind.css
Normal file
8
src/tailwind.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@layer base {
|
||||
html {
|
||||
@apply text-[#644B23];
|
||||
}
|
||||
}
|
||||
16
src/types/Friends.ts
Normal file
16
src/types/Friends.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
interface Friend {
|
||||
id: number,
|
||||
username: string,
|
||||
email: string,
|
||||
created_at: string,
|
||||
profile_image_path: string,
|
||||
is_active: boolean
|
||||
}
|
||||
interface User {
|
||||
id: number,
|
||||
username: string,
|
||||
email: string,
|
||||
created_at: string,
|
||||
profile_image_path: string,
|
||||
is_active: boolean
|
||||
}
|
||||
11
src/types/diary.ts
Normal file
11
src/types/diary.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
interface DiaryEntry {
|
||||
id: number,
|
||||
user_id: number,
|
||||
title: string,
|
||||
content: string,
|
||||
images: string[],
|
||||
category: string,
|
||||
created_at: string,
|
||||
is_submitted: boolean,
|
||||
email_sent: boolean
|
||||
}
|
||||
5
src/utils/context.ts
Normal file
5
src/utils/context.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
import { createContextId } from '@builder.io/qwik';
|
||||
|
||||
|
||||
export const AppStateContext = createContextId<User>('auth-state');
|
||||
14
src/utils/cookie.ts
Normal file
14
src/utils/cookie.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export function setCookie(name: string, value: string, days = 7) {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
document.cookie = `${name}=${encodeURIComponent(value)};expires=${expires.toUTCString()};path=/`;
|
||||
}
|
||||
export function deleteCookie(name: string) {
|
||||
document.cookie = `${name}=; expires=${new Date(0).toUTCString()}; path=/`;
|
||||
}
|
||||
export function getCookie(name: string) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
|
||||
return null;
|
||||
}
|
||||
21
src/utils/func.ts
Normal file
21
src/utils/func.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
const avatar_path = {
|
||||
남성: "public/avatar/남자.png",
|
||||
여성: "public/avatar/여자.png",
|
||||
교복: "public/avatar/교복상의.png",
|
||||
교복조끼: "public/avatar/교복조끼상의.png",
|
||||
AnA동잠: "public/avatar/동잠상의.png",
|
||||
멜빵바지: "public/avatar/멜빵바지상의.png",
|
||||
멜빵치마: "public/avatar/멜빵치마상의.png",
|
||||
무지개맨투맨: "public/avatar/무지개맨투맨상의.png",
|
||||
산타: "public/avatar/산타상의.png",
|
||||
교복바지: "public/avatar/교복하의남.png",
|
||||
교복치마: "public/avatar/교복하의여.png",
|
||||
교복조끼바지: "public/avatar/교복조끼하의남.png",
|
||||
교복조끼치마: "public/avatar/교복조끼하의여.png",
|
||||
산타바지: "public/avatar/산타하의.png",
|
||||
청바지: "public/avatar/청바지하의.png",
|
||||
}
|
||||
export const typechecker = (type: string) => {
|
||||
// console.log(type.replace(/\s+/g, ""));
|
||||
return avatar_path[type.replace(/\s+/g, "") as keyof typeof avatar_path] ?? undefined;
|
||||
}
|
||||
11
src/utils/store.ts
Normal file
11
src/utils/store.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// import { useStore } from '@builder.io/qwik';
|
||||
|
||||
// export type AuthState = {
|
||||
// userna: string | null;
|
||||
|
||||
// };
|
||||
|
||||
// // 초기값을 그대로 export하지 않고, 함수로 생성
|
||||
// export const createAppState = () => useStore<AuthState>({
|
||||
// user: null,
|
||||
// });
|
||||
29
tailwind.config.js
Normal file
29
tailwind.config.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'gradient-start': '#FEE182',
|
||||
'gradient-end': '#FFC47E',
|
||||
'text-default': '#644B23',
|
||||
'text-gray-custom': '#B1A490',
|
||||
'input-icon-color': '#FFAD84',
|
||||
'button-color-1': '#FFAD84',
|
||||
'button-color-2': '#FFAD84',
|
||||
'input-border-color': '#FFC47E',
|
||||
'diary-color': '#FFE382',
|
||||
'diary-icon-hover' : '#FFE39D',
|
||||
'button-color-3': '#FFC47E',
|
||||
'input-color': '#FFC47E',
|
||||
},
|
||||
backgroundImage: {
|
||||
'custom-gradient': 'linear-gradient(180deg, #FEE182 11%, #FFC47E 100%)',
|
||||
'signup-icon' : 'linear-gradient(180deg, #FFF78A 0%, #FFAD84 100%)'
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
|
|
@ -7,6 +7,8 @@ import { qwikVite } from "@builder.io/qwik/optimizer";
|
|||
import { qwikCity } from "@builder.io/qwik-city/vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import pkg from "./package.json";
|
||||
import tailwindcss from "tailwindcss";
|
||||
import autoprefixer from "autoprefixer";
|
||||
|
||||
type PkgDep = Record<string, string>;
|
||||
const { dependencies = {}, devDependencies = {} } = pkg as any as {
|
||||
|
|
@ -23,6 +25,11 @@ export default defineConfig(({ command, mode }): UserConfig => {
|
|||
return {
|
||||
plugins: [qwikCity(), qwikVite(), tsconfigPaths({ root: "." })],
|
||||
// This tells Vite which dependencies to pre-build in dev mode.
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [tailwindcss, autoprefixer],
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Put problematic deps that break bundling here, mostly those with binaries.
|
||||
// For example ['better-sqlite3'] if you use that in server functions.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue