63. 구독
ChatGPT에게 물었습니다.
Please create a subscription function.
구독 기능을 만들어 줘.
To create a subscription function where users can subscribe to other members' posts, we will implement the following steps:
사용자가 다른 회원의 게시물을 구독할 수 있는 구독 기능을 만들기 위해 다음 단계를 구현합니다.
- Create Firestore structure to handle subscriptions.
구독을 처리할 Firestore 구조를 만듭니다. - Implement the function to subscribe and unsubscribe from a user.
사용자의 구독 및 구독 취소 기능을 구현합니다. - Display the list of subscribed posts.
구독한 게시물 목록을 표시합니다. - Ensure that only authenticated users can subscribe.
인증된 사용자만 구독할 수 있도록 하세요.
Firestore Structure
Firestore 구조
We'll assume the following Firestore collections:
다음 Firestore 컬렉션을 가정합니다.
- users: to store user details including a list of subscriptions.
users: 구독 목록을 포함한 사용자 세부정보를 저장합니다. - posts: to store blog posts.
posts: 블로그 게시물을 저장합니다. - subscriptions: to store which users are subscribed to which other users.
구독: 어떤 사용자가 어떤 다른 사용자를 구독하는지 저장합니다.
Step 1: Setting up Firestore Structure
1단계: Firestore 구조 설정
Ensure you have a structure in your Firestore like:
Firestore에 다음과 같은 구조가 있는지 확인하세요.
users collection
{
"uid": "uniqueUserId",
"name": "UserName",
"subscriptions": ["uid1", "uid2"] // list of user IDs the user is subscribed to
}
posts collection
{
"postId": "uniquePostId",
"authorId": "uid",
"title": "Post Title",
"content": "Post content"
}
Step 2: Subscribe and Unsubscribe Functions
2단계: 구독 및 구독 취소 기능
We'll create methods to subscribe and unsubscribe users.
사용자를 구독 및 구독 취소하는 방법을 만들겠습니다.
src/components/SubscribeButton.vue
<!-- src/components/SubscribeButton.vue -->
<template>
<div>
<v-btn @click="toggleSubscription">
{{ isSubscribed ? 'Unsubscribe' : 'Subscribe' }}
</v-btn>
</div>
</template>
<script>
import { db } from '../firebase'; // Adjust the path to your firebase configuration
export default {
props: ['authorId', 'currentUser'],
data() {
return {
isSubscribed: false
};
},
async mounted() {
this.checkSubscription();
},
methods: {
async checkSubscription() {
const userDoc = await db.collection('users').doc(this.currentUser.uid).get();
if (userDoc.exists) {
this.isSubscribed = userDoc.data().subscriptions.includes(this.authorId);
}
},
async toggleSubscription() {
const userRef = db.collection('users').doc(this.currentUser.uid);
if (this.isSubscribed) {
await userRef.update({
subscriptions: firebase.firestore.FieldValue.arrayRemove(this.authorId)
});
} else {
await userRef.update({
subscriptions: firebase.firestore.FieldValue.arrayUnion(this.authorId)
});
}
this.isSubscribed = !this.isSubscribed;
}
}
};
</script>
Step 3: Display Subscribed Posts
3단계: 구독한 게시물 표시
Create a component to list posts from subscribed users.
구독한 사용자의 게시물을 나열하는 구성 요소를 만듭니다.
src/components/SubscribedPosts.vue
<!-- src/components/SubscribedPosts.vue -->
<template>
<div>
<v-list>
<v-list-item v-for="post in subscribedPosts" :key="post.id">
<v-list-item-content>
<v-list-item-title @click="viewPost(post.id)">{{ post.title }}</v-list-item-title>
<v-list-item-subtitle>{{ post.content }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</div>
</template>
<script>
import { db } from '../firebase'; // Adjust the path to your firebase configuration
export default {
data() {
return {
subscribedPosts: []
};
},
async created() {
this.fetchSubscribedPosts();
},
methods: {
async fetchSubscribedPosts() {
const userDoc = await db.collection('users').doc(this.$store.state.user.uid).get();
if (userDoc.exists) {
const subscriptions = userDoc.data().subscriptions;
if (subscriptions.length) {
const querySnapshot = await db.collection('posts').where('authorId', 'in', subscriptions).get();
this.subscribedPosts = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
}
}
},
viewPost(postId) {
this.$router.push({ name: 'PostDetail', params: { id: postId } });
}
}
};
</script>
Step 4: Ensure Only Authenticated Users Can Subscribe
4단계: 인증된 사용자만 구독할 수 있는지 확인
You can ensure this by checking the authentication state in your components.
구성 요소의 인증 상태를 확인하여 이를 확인할 수 있습니다.
Summary 요약
With these components and functions, users can subscribe and unsubscribe to other users' posts and view a list of posts from users they are subscribed to. You need to integrate the SubscribeButton component into your PostDetail component or wherever appropriate, and the SubscribedPosts component can be displayed on a dashboard or a dedicated page for viewing subscribed posts. Here’s how you can put it all together:
이러한 구성 요소와 기능을 통해 사용자는 다른 사용자의 게시물을 구독 및 구독 취소할 수 있으며 자신이 구독하는 사용자의 게시물 목록을 볼 수 있습니다. 'SubscribeButton' 구성 요소를 'PostDetail' 구성 요소 또는 적절한 곳에 통합해야 하며, 'SubscribedPosts' 구성 요소는 구독한 게시물을 보기 위해 대시보드나 전용 페이지에 표시될 수 있습니다. 모든 것을 하나로 묶는 방법은 다음과 같습니다.
src/components/PostDetail.vue
<!-- src/components/PostDetail.vue -->
<template>
<v-card>
<v-card-title>
{{ post.title }}
<SubscribeButton :authorId="post.authorId" :currentUser="currentUser" v-if="currentUser" />
</v-card-title>
<v-card-subtitle>{{ post.author }} - {{ post.createdAt.toDate().toDateString() }}</v-card-subtitle>
<v-card-text v-html="post.content"></v-card-text>
</v-card>
</template>
<script>
import { db } from '../firebase'; // Adjust the path to your firebase configuration
import SubscribeButton from './SubscribeButton.vue';
export default {
components: {
SubscribeButton
},
data() {
return {
post: null,
currentUser: this.$store.state.user
};
},
async created() {
const postId = this.$route.params.id;
const doc = await db.collection('posts').doc(postId).get();
if (doc.exists) {
this.post = doc.data();
}
}
};
</script>
This ensures that only logged-in users can subscribe to other users, and it allows them to see posts from the users they are subscribed to.
이를 통해 로그인한 사용자만 다른 사용자를 구독할 수 있으며, 자신이 구독하는 사용자의 게시물을 볼 수 있습니다.
ChatGPT에게 물었습니다.
How to handle the case where userDoc.data().subscriptions does not exist?
userDoc.data().subscriptions 가 없는 경우는 어떻게 처리하지?
To handle the case where userDoc.data().subscriptions does not exist, we can initialize the subscriptions field if it is not present in the user's document. This ensures that our code doesn't run into undefined errors when attempting to access or update the subscriptions array.
userDoc.data().subscriptions가 존재하지 않는 경우를 처리하기 위해 사용자 문서에 subscriptions 필드가 없으면 초기화할 수 있습니다. 이렇게 하면 subscriptions 배열에 액세스하거나 업데이트하려고 할 때 코드에서 정의되지 않은 오류가 발생하지 않습니다.
Here's how you can handle it:
이를 처리하는 방법은 다음과 같습니다.
- Check for the existence of the subscriptions field.
구독 필드가 있는지 확인하세요. - Initialize the field if it doesn't exist.
필드가 존재하지 않는 경우 초기화합니다. - Proceed with the subscription logic.
구독 로직을 진행합니다.
Updated SubscribeButton Component
SubscribeButton 구성요소가 업데이트 되었습니다.
Here is the updated SubscribeButton component that includes checks for the existence of the subscriptions field and initializes it if necessary:
다음은 subscriptions 필드의 존재 여부를 확인하고 필요한 경우 초기화하는 업데이트된 SubscribeButton 구성 요소입니다.
src/components/SubscribeButton.vue
<!-- src/components/SubscribeButton.vue -->
<template>
<div>
<v-btn @click="toggleSubscription">
{{ isSubscribed ? 'Unsubscribe' : 'Subscribe' }}
</v-btn>
</div>
</template>
<script>
import { db } from '../firebase'; // Adjust the path to your firebase configuration
import firebase from 'firebase/app';
export default {
props: ['authorId', 'currentUser'],
data() {
return {
isSubscribed: false
};
},
async mounted() {
this.checkSubscription();
},
methods: {
async checkSubscription() {
const userDoc = await db.collection('users').doc(this.currentUser.uid).get();
if (userDoc.exists) {
const userData = userDoc.data();
if (userData.subscriptions && userData.subscriptions.includes(this.authorId)) {
this.isSubscribed = true;
} else if (!userData.subscriptions) {
await userDoc.ref.update({ subscriptions: [] });
}
}
},
async toggleSubscription() {
const userRef = db.collection('users').doc(this.currentUser.uid);
const userDoc = await userRef.get();
if (userDoc.exists) {
const userData = userDoc.data();
const subscriptions = userData.subscriptions || [];
if (this.isSubscribed) {
// Unsubscribe logic
await userRef.update({
subscriptions: firebase.firestore.FieldValue.arrayRemove(this.authorId)
});
} else {
// Subscribe logic
await userRef.update({
subscriptions: firebase.firestore.FieldValue.arrayUnion(this.authorId)
});
}
this.isSubscribed = !this.isSubscribed;
} else {
await userRef.set({
subscriptions: [this.authorId]
});
this.isSubscribed = true;
}
}
}
};
</script>
Updated SubscribedPosts Component
SubscribedPosts 구성요소 업데이트됨
We will ensure the fetchSubscribedPosts method can handle cases where the subscriptions field does not exist:
fetchSubscribedPosts 메소드가 subscriptions 필드가 존재하지 않는 경우를 처리할 수 있는지 확인합니다.
src/components/SubscribedPosts.vue
<!-- src/components/SubscribedPosts.vue -->
<template>
<div>
<v-list>
<v-list-item v-for="post in subscribedPosts" :key="post.id">
<v-list-item-content>
<v-list-item-title @click="viewPost(post.id)">{{ post.title }}</v-list-item-title>
<v-list-item-subtitle>{{ post.content }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</div>
</template>
<script>
import { db } from '../firebase'; // Adjust the path to your firebase configuration
export default {
data() {
return {
subscribedPosts: []
};
},
async created() {
this.fetchSubscribedPosts();
},
methods: {
async fetchSubscribedPosts() {
const userDoc = await db.collection('users').doc(this.$store.state.user.uid).get();
if (userDoc.exists) {
const userData = userDoc.data();
const subscriptions = userData.subscriptions || [];
if (subscriptions.length) {
const querySnapshot = await db.collection('posts').where('authorId', 'in', subscriptions).get();
this.subscribedPosts = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
}
}
},
viewPost(postId) {
this.$router.push({ name: 'PostDetail', params: { id: postId } });
}
}
};
</script>
Summary 요약
The updated SubscribeButton component now properly handles the case where the subscriptions field might not exist by initializing it if necessary. This approach ensures that your application remains robust and avoids potential runtime errors related to undefined fields. The SubscribedPosts component also correctly handles missing subscriptions fields by defaulting to an empty array if they do not exist.
업데이트된 'SubscribeButton' 구성 요소는 이제 필요한 경우 초기화하여 'subscriptions' 필드가 없을 수 있는 경우를 올바르게 처리합니다. 이 접근 방식을 사용하면 애플리케이션이 견고하게 유지되고 정의되지 않은 필드와 관련된 잠재적인 런타임 오류를 방지할 수 있습니다. SubscribedPosts 구성 요소는 누락된 subscriptions 필드가 존재하지 않는 경우 기본적으로 빈 배열을 사용하여 올바르게 처리합니다.
With these updates, your Vue app is better equipped to handle user subscriptions to posts, ensuring a smooth user experience.
이러한 업데이트를 통해 Vue 앱은 사용자의 게시물 구독을 더 효과적으로 처리하여 원활한 사용자 경험을 보장합니다.
ChatGPT가 시키는 대로 했습니다.
1. src/components/SubscribeButton.vue
2. src/components/SubscribedPosts.vue
3. 실행
4. Source Code
src/components/SubscribeButton.vue
<!-- src/components/SubscribeButton.vue -->
<template>
<div>
<v-btn @click="toggleSubscription">
{{ isSubscribed ? 'Unsubscribe' : 'Subscribe' }}
</v-btn>
</div>
</template>
<script>
import { db } from '../firebase'; // Adjust the path to your firebase configuration
import firebase from 'firebase/compat/app';
export default {
props: ['authorId', 'currentUser'],
data() {
return {
isSubscribed: false
};
},
async mounted() {
this.checkSubscription();
},
methods: {
async checkSubscription() {
//const userDoc = await db.collection('users').doc(this.currentUser.uid).get();
const userDoc = await db.collection('users').doc(this.currentUser.id).get();
if (userDoc.exists) {
const userData = userDoc.data();
if (userData.subscriptions && userData.subscriptions.includes(this.authorId)) {
this.isSubscribed = true;
} else if (!userData.subscriptions) {
await userDoc.ref.update({ subscriptions: [] });
}
}
},
async toggleSubscription() {
//const userRef = db.collection('users').doc(this.currentUser.uid);
const userRef = db.collection('users').doc(this.currentUser.id);
if (this.isSubscribed) {
await userRef.update({
subscriptions: firebase.firestore.FieldValue.arrayRemove(this.authorId)
});
} else {
await userRef.update({
subscriptions: firebase.firestore.FieldValue.arrayUnion(this.authorId)
});
}
this.isSubscribed = !this.isSubscribed;
}
}
};
</script>
src/views/PostDetailView.vue
<!-- src/views/PostDetailView.vue -->
<template>
<v-container>
<v-progress-circular v-if="loading" indeterminate></v-progress-circular>
<v-card v-if="!loading && post">
<v-card-title>
{{ post.title }}
<v-spacer></v-spacer>
<SubscribeButton :authorId="post.userId" :currentUser="currentUser" v-if="currentUser && currentUser.id !== post.userId" />
</v-card-title>
<v-card-text>{{ post.userName }} - {{ formatDate(post.createdAt) }} Views: {{ post.views }}</v-card-text>
<!-- eslint-disable -->
<v-card-text v-html="postContent"></v-card-text>
<!-- eslint-enable -->
<!-- Comments Section -->
<v-divider class="my-4"></v-divider>
<h3>Comments</h3>
<v-list>
<v-list-item v-for="comment in comments" :key="comment.id">
<v-list-item-content>
<!-- comments -->
<v-list-item-title>{{ comment.content}}</v-list-item-title>
<v-list-item-subtitle>
{{ comment.userName }} : {{ formatDate(comment.createdAt) }}
<v-btn v-if="isCommentOwner(comment.userId)" icon @click="deleteComment(comment.id)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-list-item-subtitle>
<!-- reply -->
<v-list-item-action v-if="isAuthenticated">
<v-btn small outlined @click="showReplyForm(comment.id)"> 답글 쓰기 </v-btn>
</v-list-item-action>
<!-- reply list -->
<v-list class="ml-6" v-if="comment.replies && comment.replies.length">
<v-list-item v-for="reply in comment.replies" :key="reply.id">
<v-list-item-content>
<v-list-item-title>{{ reply.content }}</v-list-item-title>
<v-list-item-subtitle>
{{ reply.userName }} : {{ formatDate(reply.createdAt) }}
<v-btn v-if="isReplyOwner(reply.userId)" icon @click="deleteReply(comment.id, reply)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
<!-- 답글 쓰기-->
<v-form v-if="replyingTo === comment.id" @submit.prevent="addReply(comment.id)">
<v-text-field v-model="newReply" label="Add a reply" required></v-text-field>
<v-btn small text @click="showReplyForm(null)"> 취소 </v-btn>
<v-btn small text type="submit" color="primary" class="ml-2"> 답글 </v-btn>
</v-form>
<!-- reply end --->
</v-list-item-content>
</v-list-item>
</v-list>
<!-- Add Comment Form -->
<v-form v-if="isAuthenticated" @submit.prevent="addComment">
<v-textarea v-model="newComment" label="Add a comment" rows="3" required></v-textarea>
<!-- <v-text-field v-model="newComment" label="Add a comment" required></v-text-field> -->
<v-btn small outlined type="submit" color="primary"> 댓글 쓰기 </v-btn>
</v-form>
<v-alert v-else type="info" dismissible>Please log in to post a comment.</v-alert>
</v-card>
<v-alert v-model="alert" v-if="error" type="error" dismissible class="my-alert">{{ error.message }}</v-alert>
</v-container>
</template>
<script>
import { db } from '../firebase';
import firebase from 'firebase/compat/app';
import sanitizeHtml from 'sanitize-html';
import { v4 as uuidv4 } from 'uuid'; // Importing uuidv4
import SubscribeButton from '@/components/SubscribeButton.vue';
export default {
components: {
SubscribeButton
},
data() {
return {
currentUser: this.$store.state.auth.user,
post: null,
comments: [], // 댓글
newComment: '',
newReply: '',
replyingTo: null,
postContent: '',
loading: false,
error: null,
alert: true // 이것을 사용하지 않으면 alert가 dismissible를 누르면 다시 나타나지 않음
};
},
computed: {
isAuthenticated() {
//return !!auth.currentUser;
return this.$store.state.auth.user;
}
},
async created() {
this.loading = true;
const postId = this.$route.params.id;
try {
const postRef = db.collection('posts').doc(postId);
//const postDoc = await db.collection('posts').doc(postId).get();
const postDoc = await postRef.get();
if (postDoc.exists) {
this.post = postDoc.data();
this.postContent = this.sanitizeContent(this.post.content);
this.loadComments(postId);
const today = new Date().toISOString().split('T')[0];
let userId;
const user = this.$store.state.auth.user;
if (user) {
//userId = auth.currentUser.uid;
userId = user.id;
} else {
userId = this.getOrCreateAnonymousUserId();
}
const alreadyViewed = this.post.viewedBy.some(view => view.userId === userId && view.date === today);
if (!alreadyViewed) {
await postRef.update({
views: firebase.firestore.FieldValue.increment(1),
viewedBy: firebase.firestore.FieldValue.arrayUnion({ userId, date: today })
});
this.post.views += 1; // Update the local view count
}
} else {
this.error = { message: 'Post not found' };
}
} catch (error) {
this.error = error;
} finally {
this.loading = false;
}
},
methods: {
sanitizeContent(content) {
return sanitizeHtml(content.replace(/\n/g, '<br>'), {
allowedTags: ['b', 'i', 'em', 'strong', 'p', 'br'],
allowedAttributes: {}
});
},
async loadComments(postId) {
try {
const commentsSnapshot = await db.collection('posts').doc(postId).collection('comments').get();
this.comments = commentsSnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
} catch (error) {
this.error = error;
}
},
async addComment() {
// 댓글이 비었으면 저장하지 않습니다.
if(this.newComment === '') {
this.error = { message: '댓글 내용이 없습니다.' };
this.alert = true;
return;
}
//const postId = this.post.id;
const postId = this.$route.params.id;
//const userName = auth.currentUser.displayName;
const user = this.$store.state.auth.user;
const userName = user.name;
const comment = {
content: this.newComment,
userId: user.id,
userName,
createdAt: new Date(),
replies: []
};
try {
await db.collection('posts').doc(postId).collection('comments').add(comment)
.then(addedComment => {
// 추가된 댓글의 ID를 받아 넣는다.
comment.id = addedComment.id;
this.comments.push(comment);
});
this.newComment = '';
} catch (error) {
this.error = error;
}
},
async deleteComment(commentId) {
const postId = this.$route.params.id;
const commentRef = await db.collection('posts').doc(postId).collection('comments').doc(commentId);
//const commentRef = db.collection('comments').doc(commentId);
const commentDoc = await commentRef.get();
if (commentDoc.exists) {
const commentData = commentDoc.data();
if (commentData.replies.length === 0) {
await commentRef.delete();
this.comments = this.comments.filter(c => c.id !== commentId);
} else {
alert('Cannot delete a comment with replies.');
}
}
},
isCommentOwner(userId) {
return userId === this.$store.state.auth.user.id;
},
async addReply(commentId) {
if (!this.isAuthenticated) {
this.error = { message: 'Please log in to reply.' };
return;
}
if(this.newReply === '') {
this.error = { message: '답글 내용이 없습니다.' };
this.alert = true;
return;
}
//const postId = this.post.id;
//const userName = auth.currentUser.displayName;
const postId = this.$route.params.id;
const userId = this.$store.state.auth.user.id;
const userName = this.$store.state.auth.user.name;
const reply = {
id: uuidv4(),
userId,
content: this.newReply,
userName,
createdAt: new Date()
};
try {
const commentRef = db.collection('posts').doc(postId).collection('comments').doc(commentId);
await commentRef.update({
replies: firebase.firestore.FieldValue.arrayUnion(reply)
});
const comment = this.comments.find(comment => comment.id === commentId);
comment.replies.push(reply);
this.newReply = '';
this.replyingTo = null;
} catch (error) {
this.error = error;
}
},
isReplyOwner(userId) {
return userId === this.$store.state.auth.user.id;
},
async deleteReply(commentId, reply) {
const postId = this.$route.params.id;
//const commentRef = db.collection('comments').doc(commentId);
const commentRef = db.collection('posts').doc(postId).collection('comments').doc(commentId);
const commentDoc = await commentRef.get();
if (commentDoc.exists) {
const commentData = commentDoc.data();
const userId = this.$store.state.auth.user.id;
if (reply.userId === userId) {
const updatedReplies = commentData.replies.filter(r => r.id !== reply.id);
await commentRef.update({
replies: updatedReplies
});
const comment = this.comments.find(c => c.id === commentId);
if (comment) {
comment.replies = updatedReplies;
}
}
}
},
showReplyForm(commentId) {
this.replyingTo = commentId;
},
formatDate(date) {
if (date && date.toDate) {
return date.toDate().toLocaleString();
}
return '';
},
getOrCreateAnonymousUserId() {
let userId = localStorage.getItem('anonymousUserId');
if (!userId) {
userId = uuidv4();
localStorage.setItem('anonymousUserId', userId);
}
return userId;
}
}
};
</script>
<style scoped>
.my-alert {
position: fixed;
left: 10%;
bottom: 30px;
margin: 20px 0;
}
</style>
src/components/SubscribedPosts.vue
Copy<!-- src/components/SubscribedPosts.vue -->
<template>
<v-container>
<h3>구독</h3>
<v-list>
<v-list-item v-for="post in subscribedPosts" :key="post.id" @click="viewPost(post.id)">
<v-list-item-content>
<v-list-item-title>{{ post.title }}</v-list-item-title>
<v-list-item-subtitle>{{ post.content }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-container>
</template>
<script>
import { db } from '../firebase'; // Adjust the path to your firebase configuration
export default {
data() {
return {
user: null,
subscribedPosts: []
};
},
async created() {
this.user = this.$store.state.auth.user;
this.fetchSubscribedPosts();
},
methods: {
async fetchSubscribedPosts() {
//const userDoc = await db.collection('users').doc(this.$store.state.user.uid).get();
const userDoc = await db.collection('users').doc(this.user.id).get();
if (userDoc.exists) {
const subscriptions = userDoc.data().subscriptions;
if (subscriptions.length) {
//const querySnapshot = await db.collection('posts').where('authorId', 'in', subscriptions).get();
const querySnapshot = await db.collection('posts').where('userId', 'in', subscriptions).get();
this.subscribedPosts = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
}
}
},
viewPost(postId) {
//this.$router.push({ name: 'PostDetail', params: { id: postId } });
this.$router.push({ name: 'PostDetailView', params: { id: postId } });
}
}
};
</script>
'PWA' 카테고리의 다른 글
ChatGPT와 함께 PWA Blog 개발 - 완료, my-blog (0) | 2024.08.28 |
---|---|
ChatGPT와 함께 PWA Blog 개발 - 독자 (0) | 2024.08.28 |
ChatGPT와 함께 PWA Blog 개발 - 글 찾기 (0) | 2024.08.24 |
ChatGPT와 함께 PWA Blog 개발 - 댓글 삭제 (0) | 2024.08.24 |
ChatGPT와 함께 PWA Blog 개발 - 답글 (0) | 2024.08.23 |