Added libs
This commit is contained in:
1
p2post/.mtimes.json
Normal file
1
p2post/.mtimes.json
Normal file
@@ -0,0 +1 @@
|
||||
{"dataManager.py": 1757582441.3228955, "p2post.py": 1757583058.8108132, "p2postApiBlueprint.py": 1757586306.2077203, "p2postSocketio.py": 1754200648.0560544, "vue\\router.js": 1753521292.8932686, "vue\\api\\p2postApi.js": 1757586213.915512, "vue\\api\\socketEvents.js": 1754201038.0272875, "vue\\components\\CreatePost.vue": 1755344874.1115193, "vue\\components\\Feed.vue": 1755344430.197177, "vue\\components\\SideBar.vue": 1757586181.5301366, "vue\\stores\\p2postStore.js": 1754201461.7792923, "vue\\views\\HomeView.vue": 1754302593.861452}
|
||||
108
p2post/dataManager.py
Normal file
108
p2post/dataManager.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from typing import Dict, List, Optional, Any
|
||||
from libs.app.common.store import DataStore
|
||||
from libs.fspn.utils.sha256_util import hash_string
|
||||
|
||||
class DataManager():
|
||||
def __init__(self):
|
||||
self.store = DataStore(path="p2post/data.json", default_data={"users":[], "networks":[], "posts":[], "medias":[]})
|
||||
|
||||
# TODO Load networks from noSys
|
||||
|
||||
# ------------------- USER CRUD -------------------
|
||||
def add_user(self, pubkey: str, nickname: str, avatar: Optional[str] = None, bio: Optional[str] = None):
|
||||
if self.get_user(pubkey):
|
||||
raise ValueError(f"User {pubkey} already exists")
|
||||
user = {"pubkey": pubkey, "nickname": nickname, "avatar": avatar or "", "bio": bio or ""}
|
||||
self.store.add_item("users", user, unique=True, id_field="pubkey", id=pubkey)
|
||||
return user
|
||||
|
||||
def get_user(self, pubkey: str):
|
||||
return self.store.get_item("users", "pubkey", pubkey)
|
||||
|
||||
def delete_user(self, pubkey: str) -> bool:
|
||||
return self.store.remove_item("users", "pubkey", pubkey)
|
||||
|
||||
def list_users(self):
|
||||
return self.store.list_items("users")
|
||||
|
||||
def update_user(self, pubkey: str, updates: Dict[str, Any]) -> bool:
|
||||
return self.store.update_item("users", "pubkey", pubkey, updates)
|
||||
|
||||
# ---------------- NETWORK CRUD ----------------
|
||||
def add_network(self, hash: str, name: str):
|
||||
if self.get_network(hash):
|
||||
raise ValueError(f"Network {hash} already exists")
|
||||
|
||||
network = {
|
||||
"hash": hash,
|
||||
"posts": []
|
||||
}
|
||||
self.store.add_item("networks", network, unique=True, id_field="hash", id=hash)
|
||||
return network
|
||||
|
||||
def get_network(self, hash: str):
|
||||
return self.store.get_item("networks", "hash", hash)
|
||||
|
||||
def delete_network(self, hash: str) -> bool:
|
||||
return self.store.remove_item("networks", "hash", hash)
|
||||
|
||||
def list_networks(self):
|
||||
return self.store.list_items("networks")
|
||||
|
||||
def update_network(self, hash: str, updates: Dict[str, Any]) -> bool:
|
||||
return self.store.update_item("networks", "hash", hash, updates)
|
||||
|
||||
# ---------------- POST CRUD ----------------
|
||||
def add_post(self, hash: str, timestamp: int, author: str, content: str, signature: str, medias: Optional[list[Dict]] = None):
|
||||
if self.get_post(hash):
|
||||
raise ValueError(f"Post {hash} already exists")
|
||||
|
||||
post = {
|
||||
"hash": hash,
|
||||
"timestamp": timestamp,
|
||||
"author": author,
|
||||
"content": content,
|
||||
"medias": medias,
|
||||
"signature": signature
|
||||
}
|
||||
|
||||
self.store.add_item("posts", post, unique=True, id_field="hash", id=hash)
|
||||
return post
|
||||
|
||||
def get_post(self, hash: str):
|
||||
return self.store.get_item("posts", "hash", hash)
|
||||
|
||||
def delete_post(self, hash: str) -> bool:
|
||||
return self.store.remove_item("posts", "hash", hash)
|
||||
|
||||
def list_posts(self):
|
||||
return self.store.list_items("posts")
|
||||
|
||||
def update_post(self, hash: str, updates: Dict[str, Any]) -> bool:
|
||||
return self.store.update_item("posts", "hash", hash, updates)
|
||||
|
||||
# ---------------- Media ----------------
|
||||
# TODO Add field status. Create method to verify media
|
||||
def add_media(self, hash, file_path):
|
||||
if self.get_media(hash):
|
||||
raise ValueError(f"Media {hash} already exists")
|
||||
|
||||
media = {
|
||||
"hash":hash,
|
||||
"file_path": file_path
|
||||
}
|
||||
|
||||
self.store.add_item("medias", media, unique=True, id_field="hash", id=hash)
|
||||
return media
|
||||
|
||||
def get_media(self, hash: str):
|
||||
return self.store.get_item("medias", "hash", hash)
|
||||
|
||||
def delete_media(self, hash: str) -> bool:
|
||||
return self.store.remove_item("medias", "hash", hash)
|
||||
|
||||
def list_medias(self):
|
||||
return self.store.list_items("medias")
|
||||
|
||||
def update_media(self, hash: str, updates: Dict[str, Any]) -> bool:
|
||||
return self.store.update_item("medias", "hash", hash, updates)
|
||||
11
p2post/info.json
Normal file
11
p2post/info.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "p2post",
|
||||
"version": 0.014,
|
||||
"modules": [
|
||||
{
|
||||
"id": "p2post",
|
||||
"version": 0
|
||||
}
|
||||
],
|
||||
"frontend": "vue"
|
||||
}
|
||||
196
p2post/p2post.py
Normal file
196
p2post/p2post.py
Normal file
@@ -0,0 +1,196 @@
|
||||
import sys, os
|
||||
import math
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
import time
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from libs.noSys.noSysModule import NoSysModule
|
||||
from libs.noSys.events import Events as NoSysEvents, DynamicEvents as NoSysDynamicEvents
|
||||
from libs.noSys.networks import Networks
|
||||
from libs.noSys.peers import Peer
|
||||
from libs.app.common.logging import get_logger
|
||||
from libs.app.common.paths import ROOT_DIR, FILES_DIR
|
||||
from libs.fspn.utils.sha256_util import hash_file, hash_bytes
|
||||
from libs.app.common.store import DataStore
|
||||
from libs.p2post.p2postApiBlueprint import Blueprint
|
||||
from libs.p2post.p2postSocketio import HandlerSocketio
|
||||
from libs.fileTransfer.fileTransfer import FileTransfer, File, FileEvents
|
||||
from .dataManager import DataManager
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
class P2postEvents(Enum):
|
||||
ON_RECEIVING_ = auto()
|
||||
|
||||
class P2post(NoSysModule):
|
||||
def __init__(self, nosys_core):
|
||||
super().__init__(nosys_core)
|
||||
|
||||
self.data = DataManager()
|
||||
self.file_transfer:FileTransfer = None
|
||||
|
||||
self.requested_signatures = {}
|
||||
|
||||
def setup(self):
|
||||
self.nosys_core.subscribe_event(NoSysEvents.USER_ADDED, self.on_user_added)
|
||||
self.nosys_core.modules.api.register_blueprint(Blueprint(self).blueprint)
|
||||
self.nosys_core.modules.api.register_socketio(HandlerSocketio(self))
|
||||
self.networks_module:Networks = self.nosys_core.modules.get("noSys", "networks")
|
||||
self.file_transfer = self.nosys_core.modules.get("fileTransfer", "fileTransfer")
|
||||
self.file_transfer.subscribe_module_file_events(self.id, self.on_file_event)
|
||||
|
||||
def on_nosys_ready(self, event):
|
||||
pass
|
||||
|
||||
def on_user_added(self, event):
|
||||
user_id = event.user_id
|
||||
if not self.data.get_user(user_id):
|
||||
temp_nickname = user_id[:4] + "..." + user_id[-4:]
|
||||
self.data.add_user(user_id, temp_nickname)
|
||||
|
||||
def on_file_event(self, event):
|
||||
file:File = event.file
|
||||
file.subscribe_event(FileEvents.ON_FILE_COMPLETED.name, self.on_file_done)
|
||||
file.subscribe_event(FileEvents.ON_FILE_ERROR.name, self.on_file_done)
|
||||
file.subscribe_event(FileEvents.ON_FILE_UPDATE.name, self.on_file_update)
|
||||
logger.error(f"{file.status} {file.id}")
|
||||
file.approve_transfer(True) # TODO Check configuration
|
||||
|
||||
def on_file_done(self, event):
|
||||
logger.info(f"File Done {event.source.status} {event.source.hash}")
|
||||
self.data.add_media(event.source.hash, event.final_path)
|
||||
|
||||
def on_file_update(self, event):
|
||||
pass
|
||||
|
||||
def on_module_connection(self, event):
|
||||
pass
|
||||
# self.send_networks(event.connection.id)
|
||||
|
||||
def on_module_message(self, event):
|
||||
action = event.data['action']
|
||||
handler_action = getattr(self, 'on_'+action)
|
||||
handler_action(event)
|
||||
|
||||
def on_network_connection(self, event):
|
||||
network_id:str = event.network_id
|
||||
peer:Peer = event.peer
|
||||
|
||||
self.send_network_post(network_id, peer.id)
|
||||
|
||||
def send_network_post(self, network_id, peer_id):
|
||||
network = self.data.get_network(network_id)
|
||||
if network:
|
||||
body = {"action":"network_posts", "hash":network["hash"], "posts":network["posts"]}
|
||||
self.nosys_core.dispatcher.send_message(body, peer_id, self.id)
|
||||
|
||||
def on_network_posts(self, event):
|
||||
payload = event.data
|
||||
network = self.data.get_network(payload["hash"])
|
||||
if network:
|
||||
for post in payload["posts"]:
|
||||
if not self.data.get_post(post):
|
||||
self.send_get_post(post, network, event.peer.id)
|
||||
|
||||
|
||||
def send_get_post(self, hash, network, peer_id):
|
||||
body = {"action":"get_post", "hash":hash, "network":network}
|
||||
self.nosys_core.dispatcher.send_message(body, peer_id, self.id)
|
||||
|
||||
def on_get_post(self, event):
|
||||
payload = event.data
|
||||
self.send_post(payload["hash"], payload["network"], event.peer.id)
|
||||
|
||||
def send_post(self, hash, network, peer_id):
|
||||
post = self.data.get_post(hash)
|
||||
body = {"action":"post", "post":post, "network":network}
|
||||
self.nosys_core.dispatcher.send_message(body, peer_id, self.id)
|
||||
|
||||
def on_post(self, event):
|
||||
payload = event.data
|
||||
post = payload["post"]
|
||||
network = payload["network"]
|
||||
# TODO VERIFY POST
|
||||
if not self.data.get_post(post["hash"]):
|
||||
self.data.add_post(post["hash"], post["timestamp"], post["author"], post["content"], post["signature"], post["medias"])
|
||||
for media in post["medias"]:
|
||||
if media["type"] == "local" and (not self.data.get_media(media["hash"])):
|
||||
self.send_get_media(media["hash"], event.peer.id)
|
||||
self.data.get_network(network)["posts"].append(post["hash"])
|
||||
|
||||
if not self.data.get_user(post["author"]):
|
||||
author_key = post["author"]
|
||||
temp_nickname = author_key[:4] + "..." + author_key[-4:]
|
||||
self.data.add_user(author_key, temp_nickname)
|
||||
|
||||
self.data.store.save()
|
||||
|
||||
elif post["hash"] not in self.data.get_network(network)["posts"]:
|
||||
self.data.get_network(network)["posts"].append(post["hash"])
|
||||
self.data.store.save()
|
||||
|
||||
def send_get_media(self, hash, peer_id):
|
||||
body = {"action":"get_media", "hash":hash}
|
||||
self.nosys_core.dispatcher.send_message(body, peer_id, self.id)
|
||||
|
||||
def on_get_media(self, event):
|
||||
payload = event.data
|
||||
media = self.data.get_media(payload["hash"])
|
||||
self.file_transfer.send_file(media["file_path"], event.peer.id, self.id)
|
||||
|
||||
def create_post(self, author, content, medias, networks:list[str]):
|
||||
# TODO File server to respond frontend requests
|
||||
medias_data = []
|
||||
for media in medias:
|
||||
if media["type"] == "local":
|
||||
file_hash = os.path.splitext(os.path.basename(media["file_path"]))[0]
|
||||
medias_data.append({"type":"local", "hash":file_hash})
|
||||
self.data.add_media(file_hash, media["file_path"])
|
||||
else:
|
||||
medias_data.append({"type":media["type"], "url":media["url"]})
|
||||
|
||||
current_utc_datetime = datetime.now(timezone.utc)
|
||||
utc_timestamp = current_utc_datetime.timestamp()
|
||||
post = {
|
||||
"timestamp": utc_timestamp,
|
||||
"author": author,
|
||||
"content": content,
|
||||
"medias": medias_data
|
||||
}
|
||||
post_serialized = json.dumps(post, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
post["hash"] = hash_bytes(post_serialized.encode('utf-8'))
|
||||
request_id = self.nosys_core.modules.pmc.sign(post["hash"], author, self.signature_callback, f"New Post {post}")
|
||||
self.requested_signatures[request_id] = (post, networks)
|
||||
logger.debug(f"Post waiting signature {request_id}: {post}")
|
||||
return post["hash"]
|
||||
|
||||
def signature_callback(self, request_id, signature):
|
||||
post, networks = self.requested_signatures[request_id]
|
||||
if signature:
|
||||
self.data.add_post(post["hash"], post["timestamp"], post["author"], post["content"], signature, post["medias"])
|
||||
for network in networks:
|
||||
self.data.get_network(network)["posts"].append(post["hash"])
|
||||
network_state = self.networks_module.network_states.get(network)
|
||||
for peer in network_state["peers"]:
|
||||
self.send_post(post["hash"], network, peer)
|
||||
self.data.store.save()
|
||||
|
||||
def test(self):
|
||||
file_path = r"C:\Workspace\libs\noSys\index.html"
|
||||
file_url = "https://pbs.twimg.com/media/GwvwQamW4AAQYiD?format=jpg&name=small"
|
||||
|
||||
medias = []
|
||||
medias.append({
|
||||
"type":"local",
|
||||
"file_path": file_path
|
||||
})
|
||||
medias.append({
|
||||
"type":"external",
|
||||
"url": file_url
|
||||
})
|
||||
self.create_post("A4DZSk+TlR+4w39MbiIAQbti+N0H1QlJEhRH2DI6Iubj", "ContentTest", medias, ["629985fd999a675072a2e3f22f74e60c62e8c5b8efb21478c9b04e7ff2fe9779",])
|
||||
|
||||
|
||||
BIN
p2post/p2post.zip
Normal file
BIN
p2post/p2post.zip
Normal file
Binary file not shown.
94
p2post/p2postApiBlueprint.py
Normal file
94
p2post/p2postApiBlueprint.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from libs.api.apiBlueprint import ApiBlueprint
|
||||
from libs.fspn.utils.sha256_util import hash_bytes
|
||||
from libs.app.common.paths import FILES_DIR
|
||||
|
||||
from flask import jsonify, request, send_from_directory
|
||||
|
||||
import os
|
||||
import copy
|
||||
import random
|
||||
|
||||
MEDIAS_FOLDER = os.path.join(FILES_DIR, "p2post", "medias")
|
||||
|
||||
class Blueprint(ApiBlueprint):
|
||||
def routes(self):
|
||||
from .p2post import P2post
|
||||
self.module:P2post = self.module
|
||||
|
||||
@self.blueprint.route('/')
|
||||
def show():
|
||||
return self.module.name
|
||||
|
||||
@self.blueprint.route('/myusers', methods=["GET", "POST"])
|
||||
def my_users():
|
||||
if request.method == "GET":
|
||||
users = []
|
||||
for user_id in self.module.nosys_core.users.users:
|
||||
user = self.module.data.get_user(user_id)
|
||||
if user:
|
||||
user["data"] = self.module.nosys_core.data.get_user(user_id)
|
||||
users.append(user)
|
||||
return jsonify(users)
|
||||
|
||||
elif request.method == "POST":
|
||||
content:dict = request.json
|
||||
self.module.data.add_user(content["pubkey"], content["nickname"], content["avatar"], content["bio"])
|
||||
return jsonify({"pubkey":content["pubkey"]})
|
||||
|
||||
|
||||
@self.blueprint.route('/users', methods=["GET", "POST"])
|
||||
def users():
|
||||
if request.method == "GET":
|
||||
users = self.module.data.list_users()
|
||||
return jsonify({"users":users})
|
||||
|
||||
elif request.method == "POST":
|
||||
content:dict = request.json
|
||||
self.module.data.add_user(content["pubkey"], content["nickname"], content["avatar"], content["bio"])
|
||||
return jsonify({"pubkey":content["pubkey"]})
|
||||
|
||||
@self.blueprint.route('/posts', methods=["GET", "POST"])
|
||||
def posts():
|
||||
if request.method == "GET":
|
||||
raw_posts = self.module.data.list_posts()
|
||||
posts = copy.deepcopy(raw_posts)
|
||||
for post in posts:
|
||||
for media in post.get("medias"):
|
||||
if media.get("type") == "local":
|
||||
media_info = self.module.data.get_media(media.get("hash"))
|
||||
media["file_path"] = media_info.get("file_path")
|
||||
return jsonify(posts)
|
||||
|
||||
elif request.method == "POST":
|
||||
content:dict = request.json
|
||||
hash = self.module.create_post(content["user"], content["content"], content["medias"], content["networks"])
|
||||
return jsonify(hash)
|
||||
|
||||
@self.blueprint.route('/medias', methods=['POST'])
|
||||
def medias():
|
||||
file = request.files.get("file")
|
||||
if not file:
|
||||
return jsonify({"error": "Empty file"}), 400
|
||||
|
||||
file_bytes = file.read()
|
||||
file_hash = hash_bytes(file_bytes)
|
||||
file_ext = os.path.splitext(file.filename)[1]
|
||||
file_path = os.path.join(MEDIAS_FOLDER, f"{file_hash}{file_ext}")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_bytes)
|
||||
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"hash": file_hash,
|
||||
"path": f"{file_hash}{file_ext}"
|
||||
})
|
||||
|
||||
@self.blueprint.route('/medias/<filename>')
|
||||
def media_file(filename):
|
||||
return send_from_directory(MEDIAS_FOLDER, filename)
|
||||
|
||||
|
||||
|
||||
|
||||
26
p2post/p2postSocketio.py
Normal file
26
p2post/p2postSocketio.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from flask import Blueprint, make_response, request, jsonify, session, request
|
||||
from flask_socketio import SocketIO, emit, join_room, leave_room
|
||||
|
||||
from libs.api.eventsSocketio import EventsSocketio
|
||||
|
||||
import os, json
|
||||
import logging
|
||||
|
||||
class HandlerSocketio(EventsSocketio):
|
||||
def events(self):
|
||||
@self.socketio.on("message", namespace=self.namespace)
|
||||
def on_message(*args, **kwargs):
|
||||
print('P2Post Message',args, kwargs)
|
||||
self.socketio.send(f"Message from P2Post {args[0]}", namespace=self.namespace)
|
||||
|
||||
@self.socketio.on("ola", namespace=self.namespace)
|
||||
def on_test(*args, **kwargs):
|
||||
print('P2Post ola',args, kwargs)
|
||||
self.socketio.emit("ola",f"Ola from P2Post {args[0]}", namespace=self.namespace)
|
||||
|
||||
|
||||
def send_test(self, message):
|
||||
self.socketio.emit("test",message, namespace=self.namespace)
|
||||
|
||||
def emit_new_user(self, user):
|
||||
self.socketio.emit("newUser", user, namespace=self.namespace)
|
||||
86
p2post/vue/api/p2postApi.js
Normal file
86
p2post/vue/api/p2postApi.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useP2postStore } from '../stores/p2postStore'
|
||||
|
||||
const p2postStore = useP2postStore()
|
||||
|
||||
const p2postApiUrl = "/api/p2post"
|
||||
|
||||
export const p2postApi = {
|
||||
async getMyUsers(){
|
||||
try {
|
||||
const response = await fetch(p2postApiUrl+'/myusers')
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error fetching posts: ${response.status} - ${errorText}`);
|
||||
}
|
||||
const result = await response.json()
|
||||
p2postStore.users = result
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error fetching posts:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getPosts(){
|
||||
try {
|
||||
const response = await fetch(p2postApiUrl+'/posts')
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error fetching posts: ${response.status} - ${errorText}`);
|
||||
}
|
||||
const result = await response.json()
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error fetching posts:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async createPosts(user, content, medias, networks){
|
||||
const data = {"user": user, "content": content, "medias": medias, "networks": networks};
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json',},
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
try {
|
||||
const response = await fetch(p2postApiUrl+"/posts", requestOptions);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error fetching posts: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching posts:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
async uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const response = await fetch(p2postApiUrl + "/medias", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error uploading file: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
p2post/vue/api/socketEvents.js
Normal file
15
p2post/vue/api/socketEvents.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useP2postStore } from '../stores/p2postStore'
|
||||
|
||||
const p2postStore = useP2postStore()
|
||||
|
||||
export default function registerSocketEvents(socket) {
|
||||
socket.on('connect', (data) => {
|
||||
console.log('Connected p2Post')
|
||||
}),
|
||||
|
||||
socket.on('newPost', (data) => {
|
||||
p2postStore.addPost(data)
|
||||
console.log('New Post', data)
|
||||
})
|
||||
|
||||
}
|
||||
221
p2post/vue/components/CreatePost.vue
Normal file
221
p2post/vue/components/CreatePost.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<script setup>
|
||||
import Card from '@/components/cards/Card.vue';
|
||||
import { p2postApi } from '../api/p2postApi'
|
||||
import { ref } from 'vue';
|
||||
import InputComboBox from '@/components/inputs/InputComboBox.vue';
|
||||
import Label from '@/components/labels/Label.vue';
|
||||
import InputText from '@/components/inputs/InputText.vue';
|
||||
import Button from '@/components/buttons/Button.vue';
|
||||
import { ArrowUpTrayIcon, ChatBubbleBottomCenterTextIcon, RocketLaunchIcon, XMarkIcon } from '@heroicons/vue/24/solid';
|
||||
|
||||
const newPostUser = ref(null);
|
||||
const newPostContent = ref("");
|
||||
const newPostFiles = ref([]);
|
||||
const newPostFilesPreview = ref([]);
|
||||
const newPostNetworks = ref([]);
|
||||
|
||||
const onFileSelected = (e) => {
|
||||
const newFiles = Array.from(e.target.files);
|
||||
|
||||
newFiles.forEach(file => {
|
||||
const exists = newPostFiles.value.some(
|
||||
f => f.name === file.name && f.size === file.size
|
||||
);
|
||||
if (!exists) {
|
||||
newPostFiles.value.push(file);
|
||||
newPostFilesPreview.value.push({
|
||||
file,
|
||||
url: URL.createObjectURL(file),
|
||||
type: file.type
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
e.target.value = null;
|
||||
};
|
||||
|
||||
function removePreview(index) {
|
||||
const [removed] = newPostFilesPreview.value.splice(index, 1);
|
||||
URL.revokeObjectURL(removed.url);
|
||||
newPostFiles.value.splice(index, 1);
|
||||
}
|
||||
|
||||
async function createPost(){
|
||||
// TODO Select users
|
||||
const user = "A4DZSk+TlR+4w39MbiIAQbti+N0H1QlJEhRH2DI6Iubj"
|
||||
const content = newPostContent.value
|
||||
var medias = []
|
||||
for (var file of newPostFilesPreview.value){
|
||||
const response = await p2postApi.uploadFile(file.file)
|
||||
console.log(response)
|
||||
medias.push({type: "local",
|
||||
"file_path": response.path})
|
||||
}
|
||||
// TODO Select networks
|
||||
const networks = ["net"]
|
||||
|
||||
const response = await p2postApi.createPosts(user, content, medias, networks)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="bg-black border-yellow-400/20 p-6">
|
||||
<h2 class="text-xl font-bold text-yellow-400 mb-6 flex items-center">
|
||||
<ChatBubbleBottomCenterTextIcon class="mr-2 h-5 w-5" />
|
||||
Create New Post
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label class="text-gray-300 mb-2 block">Select User</Label>
|
||||
<InputComboBox/>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-black border border-yellow-400/20 rounded-lg p-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- <img src={user.photo || "/placeholder.svg"} alt="" class="w-10 h-10 rounded-full" /> -->
|
||||
<div>
|
||||
<p class="text-white font-semibold">{user.nickname}</p>
|
||||
<p class="text-xs text-gray-400 font-mono">{user.publicKey}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="text-gray-300 mb-2 block">Post Content</Label>
|
||||
<InputText
|
||||
value={postContent}
|
||||
placeholder="Share your thoughts with the decentralized world..."
|
||||
class="bg-black border-yellow-400/30 text-white min-h-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<Button
|
||||
@click=""
|
||||
variant="outline"
|
||||
class="border-yellow-400 text-yellow-400 hover:bg-yellow-400 hover:text-black bg-transparent"
|
||||
>
|
||||
<ArrowUpTrayIcon class="mr-2 h-4 w-4" />
|
||||
Add Files
|
||||
</Button>
|
||||
|
||||
<div class="flex-1 bg-black border border-yellow-400/20 rounded-lg p-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div key={file} class="flex items-center space-x-2 bg-gray-800 rounded px-2 py-1">
|
||||
<File size={14} class="text-yellow-400" />
|
||||
<span class="text-white text-sm">{file}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="text-red-400 hover:bg-red-400/20 p-0 h-auto"
|
||||
>
|
||||
<XMarkIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="text-gray-300 mb-2 block">Select Networks</Label>
|
||||
<!-- <div class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<div key={network.id} class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={network.id}
|
||||
checked={selectedNetworks.includes(network.name)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedNetworks((prev) => [...prev, network.name])
|
||||
} else {
|
||||
setSelectedNetworks((prev) => prev.filter((n) => n !== network.name))
|
||||
}
|
||||
class="border-yellow-400 data-[state=checked]:bg-yellow-400 data-[state=checked]:border-yellow-400"
|
||||
/>
|
||||
<Label htmlFor={network.id} class="text-white text-sm">
|
||||
{network.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click=""
|
||||
class="bg-yellow-400 text-black hover:bg-yellow-300 font-bold"
|
||||
>
|
||||
<RocketLaunchIcon class="h-4 w-4 mr-2" />
|
||||
Post to Networks
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- <div class="bg-gray-800 rounded-xl shadow p-4 mb-6 text-white">
|
||||
<textarea
|
||||
v-model="newPostContent"
|
||||
placeholder="Share something with the network..."
|
||||
class="w-full p-2 bg-gray-700 border border-gray-600 rounded mb-2"
|
||||
rows="3"
|
||||
></textarea>
|
||||
|
||||
|
||||
<div
|
||||
v-for="(preview, index) in newPostFilesPreview"
|
||||
:key="preview.url"
|
||||
class="bg-gray-700 p-2 rounded relative flex items-center justify-center h-40"
|
||||
>
|
||||
|
||||
<button
|
||||
@click="removePreview(index)"
|
||||
class="absolute top-1 right-1 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-700"
|
||||
title="Remover"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
|
||||
<img
|
||||
v-if="preview.type.startsWith('image/')"
|
||||
:src="preview.url"
|
||||
class="object-contain max-h-full max-w-full"
|
||||
alt="Preview"
|
||||
/>
|
||||
|
||||
|
||||
<audio
|
||||
v-else-if="preview.type.startsWith('audio/')"
|
||||
:src="preview.url"
|
||||
controls
|
||||
class="w-full"
|
||||
></audio>
|
||||
|
||||
|
||||
<video
|
||||
v-else-if="preview.type.startsWith('video/')"
|
||||
:src="preview.url"
|
||||
controls
|
||||
class="object-contain max-h-full max-w-full"
|
||||
></video>
|
||||
|
||||
|
||||
<div v-else class="text-sm text-center px-2 break-all">
|
||||
{{ preview.file.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<input type="file" multiple @change="onFileSelected" class="text-sm text-white" />
|
||||
<button
|
||||
@click="createPost"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div> -->
|
||||
</template>
|
||||
79
p2post/vue/components/Feed.vue
Normal file
79
p2post/vue/components/Feed.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import Card from '@/components/cards/Card.vue';
|
||||
import { useP2postStore } from '../stores/p2postStore';
|
||||
import InputComboBox from '@/components/inputs/InputComboBox.vue';
|
||||
import { ClockIcon, FunnelIcon, HashtagIcon } from '@heroicons/vue/24/solid';
|
||||
|
||||
const p2postStore = useP2postStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="bg-black border-yellow-400/20 p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-yellow-400 flex items-center">
|
||||
<HashtagIcon class="mr-2 h-5 w-5" />
|
||||
Feed
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<FunnelIcon class="text-gray-400 h-5 w-5" />
|
||||
<InputComboBox></InputComboBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div key={post.id} class="bg-black border border-yellow-400/20 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- <img src={post.photo || "/placeholder.svg"} alt="" class="w-12 h-12 rounded-full" /> -->
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-white font-semibold">{post.nickname}</span>
|
||||
<div class="flex items-center space-x-1">
|
||||
<Shield size={14} class="text-yellow-400" />
|
||||
<span class="text-yellow-400 text-sm">PoW: {post.powDifficulty}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 font-mono">{post.userPublicKey}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-1 text-gray-400 text-sm">
|
||||
<ClockIcon class="h-4 w-4"/>
|
||||
<span>{new Date(post.postedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<p class="text-white leading-relaxed">{post.content}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
FILES
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
||||
<span key={network} class="bg-yellow-400 text-black text-sm px-2 py-1 rounded-full">
|
||||
{network}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<!-- <div v-for="post in p2postStore.posts.value" :key="post.hash" class="bg-gray-800 p-4 rounded-xl shadow mb-4">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<img :src="post.author.avatar" class="w-8 h-8 rounded-full" alt="Avatar" />
|
||||
<div>
|
||||
<p class="text-sm font-semibold">{{ post.author.nickname }}</p>
|
||||
<p class="text-xs text-gray-400">{{ formatTimestamp(post.timestamp) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-200 whitespace-pre-wrap">{{ post.content }}</p>
|
||||
<div v-for="media in post.media">
|
||||
<img v-if="media.type === 'external'" :src="media.url" class="mt-2 rounded max-h-60" alt="Media" />
|
||||
<img v-if="media.type === 'local'" :src="media.file_path" class="mt-2 rounded max-h-60" alt="Media" />
|
||||
</div>
|
||||
</div> -->
|
||||
</template>
|
||||
219
p2post/vue/components/SideBar.vue
Normal file
219
p2post/vue/components/SideBar.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<script setup>
|
||||
import Card from '@/components/cards/Card.vue';
|
||||
import { useP2postStore } from '../stores/p2postStore';
|
||||
import { ref } from 'vue';
|
||||
import InputText from '@/components/inputs/InputText.vue';
|
||||
import Button from '@/components/buttons/Button.vue';
|
||||
import { CommandLineIcon, GlobeAltIcon, PencilIcon, PlusIcon, UsersIcon, XMarkIcon } from '@heroicons/vue/24/solid';
|
||||
import { p2postApi } from '../api/p2postApi';
|
||||
import { noSysApi } from '@/modules/noSys/api/noSysApi';
|
||||
|
||||
const p2postStore = useP2postStore()
|
||||
|
||||
const newNetworkName = ref("")
|
||||
|
||||
async function loadData(){
|
||||
const networks = await noSysApi.listNetworks()
|
||||
const users = await p2postApi.getMyUsers()
|
||||
console.log(users)
|
||||
for(const user of users){
|
||||
const userData = await noSysApi.getUser(user.pubkey)
|
||||
for(const network of userData.networks){
|
||||
p2postStore.networks.push(networks.find(n => n.data.id === network))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-80 bg-black border-r border-yellow-400/20 p-6 space-y-6">
|
||||
<Card class="bg-black border-yellow-400/20 p-4">
|
||||
<h3 class="text-lg font-bold text-yellow-400 mb-4 flex items-center">
|
||||
<UsersIcon class="h-5 w-5 mr-2" />
|
||||
My Users
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-for="user in p2postStore.users" class="bg-black border border-yellow-400/20 rounded-lg p-3">
|
||||
<div v-if="false" class="space-y-2">
|
||||
<InputText
|
||||
value={editNickname}
|
||||
placeholder="Nickname"
|
||||
class="bg-gray-800 border-yellow-400/30 text-white text-sm"
|
||||
/>
|
||||
<InputText
|
||||
value={editBio}
|
||||
placeholder="Bio"
|
||||
class="bg-gray-800 border-yellow-400/30 text-white text-sm h-16"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@click=""
|
||||
class="bg-yellow-400 text-black hover:bg-yellow-300"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
@click=""
|
||||
class="border-gray-400 text-gray-400"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="true">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- <img src={user.photo || "/placeholder.svg"} alt="" class="w-8 h-8 rounded-full" /> -->
|
||||
<span class="text-white font-semibold text-sm">{{user.nickname}}</span>
|
||||
</div>
|
||||
<Button
|
||||
@click=""
|
||||
class="text-black hover:bg-yellow-400/20"
|
||||
>
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 font-mono mb-1">{{user.pubkey}}</p>
|
||||
<p class="text-xs text-gray-300">{{user.bio}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="bg-black border-yellow-400/20 p-4">
|
||||
<h3 class="text-lg font-bold text-yellow-400 mb-4 flex items-center">
|
||||
<GlobeAltIcon class="h-5 w-5 mr-2" />
|
||||
Networks
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3 mb-4">
|
||||
<div v-for="network in p2postStore.networks" class="bg-black border border-yellow-400/20 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-white font-semibold">{{network.data.name}}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click=""
|
||||
class="text-black hover:bg-yellow-400"
|
||||
>
|
||||
<XMarkIcon class="h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 space-y-1">
|
||||
<div>{{network.data.id}}</div>
|
||||
<div>Posts: {{network.totalPosts}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<InputText
|
||||
value={newNetworkName}
|
||||
placeholder="Network name"
|
||||
class="bg-black border-yellow-400/30 text-white text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={addNetwork} class="bg-yellow-400 text-black hover:bg-yellow-300">
|
||||
<PlusIcon class="h-4 w-4"/>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card v-if="false" class="bg-black border-yellow-400/20 p-4 flex-1">
|
||||
<h3 class="text-lg font-bold text-yellow-400 mb-4 flex items-center">
|
||||
<CommandLineIcon class="h-5 w-5 mr-2" />
|
||||
System Logs
|
||||
</h3>
|
||||
|
||||
<div
|
||||
ref={logsRef}
|
||||
class="bg-black border border-yellow-400/20 rounded-lg p-3 h-64 overflow-y-auto font-mono text-xs"
|
||||
>
|
||||
<!-- V-for logs -->
|
||||
<div key={log.id} class="mb-1">
|
||||
<span class="text-gray-500">{new Date(log.timestamp).toLocaleTimeString()}</span>
|
||||
<span>
|
||||
<!-- TODO log level/type colors -->
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<!-- <aside class="w-64 bg-gray-800 shadow-md p-4 overflow-y-auto">
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-2">Accounts</h3>
|
||||
<div v-for="peer in p2postStore.peers.value" :key="peer.pubkey" class="flex items-center space-x-2 mb-2">
|
||||
<img :src="peer.avatar" class="w-8 h-8 rounded-full" alt="Avatar" />
|
||||
<span class="text-sm font-medium">{{ peer.nickname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-2">Networks</h3>
|
||||
<ul>
|
||||
<li
|
||||
v-for="network in p2postStore.networks.value"
|
||||
:key="network.name"
|
||||
@click="selectNetwork(network)"
|
||||
class="cursor-pointer hover:text-blue-400"
|
||||
>
|
||||
<span class="font-medium">{{ network.name }}</span>
|
||||
<span class="text-sm text-gray-400 block">
|
||||
{{ network.peers }} peers, {{ network.posts }} posts
|
||||
</span>
|
||||
<button @click="removeNetwork(network.name)" class="text-red-400 hover:underline">
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form @submit.prevent="addNetwork" class="mb-4">
|
||||
<input
|
||||
v-model="newNetworkName"
|
||||
type="text"
|
||||
placeholder="New network..."
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded p-2 mb-2 text-sm text-white"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-green-600 text-white text-sm py-1 rounded hover:bg-green-700"
|
||||
>
|
||||
Add Network
|
||||
</button>
|
||||
</form>
|
||||
</div> -->
|
||||
|
||||
<!-- <div class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-2">Accounts</h3>
|
||||
<form @submit.prevent="updateUser" class="space-y-2">
|
||||
<input
|
||||
v-model="me.nickname"
|
||||
type="text"
|
||||
placeholder="Your nickname"
|
||||
class="w-full bg-gray-700 border border-gray-600 rounded p-2 text-sm text-white"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
@change="onAvatarSelected"
|
||||
class="text-sm text-white"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-blue-600 text-white text-sm py-1 rounded hover:bg-blue-700"
|
||||
>
|
||||
Update Profile
|
||||
</button>
|
||||
</form>
|
||||
<div class="mt-2 text-sm text-gray-400">
|
||||
<p><strong>Public Key:</strong></p>
|
||||
<p class="break-all text-xs text-gray-500">{{ me.pubkey }}</p>
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- </aside> -->
|
||||
</template>
|
||||
7
p2post/vue/router.js
Normal file
7
p2post/vue/router.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import HomeView from "./views/HomeView.vue";
|
||||
|
||||
const routes = [
|
||||
{path: '/', name:'p2post', component: HomeView},
|
||||
]
|
||||
|
||||
export {routes};
|
||||
16
p2post/vue/stores/p2postStore.js
Normal file
16
p2post/vue/stores/p2postStore.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useP2postStore = defineStore('p2postStore', {
|
||||
state: () => ({
|
||||
users: [],
|
||||
networks: [],
|
||||
}),
|
||||
|
||||
getters: {
|
||||
|
||||
},
|
||||
|
||||
actions: {
|
||||
|
||||
}
|
||||
})
|
||||
57
p2post/vue/views/HomeView.vue
Normal file
57
p2post/vue/views/HomeView.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { p2postApi } from '../api/p2postApi'
|
||||
import CreatePost from '../components/CreatePost.vue'
|
||||
import SideBar from '../components/SideBar.vue';
|
||||
import Feed from '../components/Feed.vue'
|
||||
import { useP2postStore } from '../stores/p2postStore';
|
||||
|
||||
const p2postStore = useP2postStore()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-black text-yellow-400">
|
||||
<header class="border-b border-yellow-400/20 py-6">
|
||||
<div class="container mx-auto px-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-3xl font-black">
|
||||
<span class="text-yellow-400">P2</span>
|
||||
<span class="text-white">POST</span>
|
||||
</div>
|
||||
<div class="w-1 h-8 bg-yellow-400"></div>
|
||||
<span class="text-gray-400">Decentralized Social Media</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex min-h-screen">
|
||||
<SideBar/>
|
||||
<div class="flex-1 p-6 space-y-6">
|
||||
<CreatePost/>
|
||||
<Feed/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- <div class="flex h-screen bg-gray-900 text-white">
|
||||
<SideBar/>
|
||||
<main class="flex-1 p-6 overflow-y-auto">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<CreatePost/>
|
||||
<Feed/>
|
||||
</div>
|
||||
</main>
|
||||
</div> -->
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
background-color: #111827;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user