mirror of
https://github.com/ION606/bluesky-client.git
synced 2026-05-14 21:26:54 +00:00
initial code commit
This commit is contained in:
+6
-129
@@ -1,130 +1,7 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
secrets/
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
tmp/
|
||||
cache/
|
||||
logs/
|
||||
db/
|
||||
TODO.*
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
secrets/
|
||||
node_modules/
|
||||
tmp/
|
||||
cache/
|
||||
logs/
|
||||
db/
|
||||
TODO.*
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
/* posts */
|
||||
/* container for all cards */
|
||||
.cards-container {
|
||||
column-width: 280px; /* sets the width of each "column" */
|
||||
column-gap: 16px; /* horizontal space between columns */
|
||||
padding: 20px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.cards-container .post-card {
|
||||
position: relative;
|
||||
background-color: #2e003e;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
color: #e6e6ff;
|
||||
font-family: Arial, sans-serif;
|
||||
display: inline-block; /* makes each card flow naturally into columns */
|
||||
margin-bottom: 16px; /* adds space between items vertically */
|
||||
width: 100%; /* ensures card takes up the column width */
|
||||
}
|
||||
|
||||
|
||||
.cards-container-relaxed {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-auto-rows: auto; /* allows each card to be only as tall as its content */
|
||||
grid-auto-flow: dense; /* fills in gaps with smaller items if available */
|
||||
padding: 20px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.cards-container-relaxed .post-card {
|
||||
position: relative;
|
||||
background-color: #2e003e;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
color: #e6e6ff;
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* author section inside card */
|
||||
.author-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* avatar image styling */
|
||||
.card-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
border: 2px solid #aa00ff;
|
||||
}
|
||||
|
||||
/* author info */
|
||||
.author-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* display name and handle styling */
|
||||
.author-name {
|
||||
font-size: 1em;
|
||||
color: #d4b0ff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.author-handle {
|
||||
font-size: 0.8em;
|
||||
color: #b399ff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* text content styling */
|
||||
.post-text {
|
||||
overflow: hidden;
|
||||
max-height: 200px; /* set a limit for text content */
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
|
||||
/* styling for original post preview */
|
||||
.original-post {
|
||||
background-color: #3d1c3d;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
font-size: 0.8em;
|
||||
color: #b399ff;
|
||||
}
|
||||
|
||||
.original-author {
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
color: #d4b0ff;
|
||||
}
|
||||
|
||||
.original-text {
|
||||
font-style: italic;
|
||||
color: #e6e6ff;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.original-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* interaction counts */
|
||||
.interaction-counts {
|
||||
font-size: 0.8em;
|
||||
color: #b399ff;
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* repost section styling */
|
||||
.repost-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #3d1c3d;
|
||||
padding: 8px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* repost avatar */
|
||||
.repost-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
border: 2px solid #ff66ff;
|
||||
}
|
||||
|
||||
/* repost info */
|
||||
.repost-info .author-name {
|
||||
font-size: 0.9em;
|
||||
color: #d4b0ff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.repost-info .author-handle {
|
||||
font-size: 0.8em;
|
||||
color: #b399ff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* styling for post image within card */
|
||||
.post-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
|
||||
/* pinned label styling */
|
||||
.pinned-label {
|
||||
background-color: #ffcc00; /* bright color to make it noticeable */
|
||||
color: #333;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
margin-bottom: 8px; /* space below the label */
|
||||
}
|
||||
|
||||
/* optional: distinct border for pinned card */
|
||||
.pinned-card {
|
||||
border: 2px solid #ffcc00; /* match border with label color */
|
||||
padding-top: 8px; /* adjust padding for the label */
|
||||
}
|
||||
|
||||
/* container for video within post card */
|
||||
.video-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-top: 10px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* video element styling */
|
||||
.post-video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
background-color: #000;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* add a slight shadow to the video container for depth */
|
||||
.video-container {
|
||||
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* hover effect to add a border around the video for focus */
|
||||
.video-container:hover {
|
||||
border: 2px solid #aa00ff;
|
||||
transition: border 0.3s ease;
|
||||
}
|
||||
|
||||
/* compose button styling with icon */
|
||||
.compose-button {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 2%;
|
||||
background-color: #6b006b;
|
||||
color: #e6e6ff;
|
||||
padding: 15px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.compose-button:hover {
|
||||
background-color: #aa00ff;
|
||||
}
|
||||
|
||||
/* button container styling */
|
||||
.button-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* individual action buttons */
|
||||
.action-button {
|
||||
background-color: #2a002e;
|
||||
color: #e6e6ff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background-color: #4e004e;
|
||||
}
|
||||
|
||||
/* specific styles for icons */
|
||||
.like-button {
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
.dm-button {
|
||||
color: #66ccff;
|
||||
}
|
||||
|
||||
.copy-link-button {
|
||||
color: #ffcc00;
|
||||
}
|
||||
|
||||
.pin-button {
|
||||
color: #ffcc00;
|
||||
}
|
||||
|
||||
/* dropdown menu styling */
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
right: 13px;
|
||||
background-color: #3d1c3d;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* hide dropdown menu initially */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* dropdown action button styling */
|
||||
.dropdown-menu .action-button {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
/* overlay backdrop */
|
||||
#overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7); /* darker overlay for dark theme */
|
||||
z-index: 999;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* popup form styling for dark theme */
|
||||
#new-post-form {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #333; /* dark background */
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
color: #f1f1f1; /* light text for contrast */
|
||||
}
|
||||
|
||||
/* show popup and overlay */
|
||||
#new-post-form.show, #overlay.show {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* form elements styling */
|
||||
#postContent, #postTitle, #postFile, #postGif, #postAudio {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
background-color: #444; /* dark background for inputs */
|
||||
border: 1px solid #666;
|
||||
border-radius: 4px;
|
||||
color: #f1f1f1; /* light text */
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
font-size: 16px;
|
||||
background-color: #0077ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
button[type="submit"]:hover {
|
||||
background-color: #005bb5;
|
||||
}
|
||||
|
||||
/* close button */
|
||||
#closeButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
color: #f1f1f1; /* light color for dark background */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* button styling */
|
||||
button#fileButton, button#gifButton, button#audioButton {
|
||||
background: #444; /* dark background for dark theme */
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
margin: 5px 0;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
button#fileButton:hover, button#gifButton:hover, button#audioButton:hover {
|
||||
background-color: #555; /* slightly lighter on hover */
|
||||
}
|
||||
|
||||
/* icon styling */
|
||||
button#fileButton img, button#gifButton img, button#audioButton img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* hide actual input fields */
|
||||
#postFile, #postGif, #postAudio {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#new-post-form button {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Updated Embed widget styling for dark theme */
|
||||
.embed-widget {
|
||||
display: none; /* Hide by default */
|
||||
border: 1px solid #666;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 20px;
|
||||
background-color: #333; /* Dark background */
|
||||
color: #f1f1f1; /* Light text for contrast */
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Input styling inside the widget for dark theme */
|
||||
.embed-widget input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 12px;
|
||||
background-color: #444; /* Dark background for inputs */
|
||||
border: 1px solid #666;
|
||||
border-radius: 4px;
|
||||
color: #f1f1f1; /* Light text */
|
||||
}
|
||||
|
||||
/* Label styling */
|
||||
.embed-widget label {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #f1f1f1; /* Light color for dark theme */
|
||||
}
|
||||
|
||||
/* Button styling for adding an external embed */
|
||||
#showEmbedButton {
|
||||
margin-top: 10px;
|
||||
padding: 10px 16px;
|
||||
font-size: 16px;
|
||||
background-color: #007acc;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
#showEmbedButton:hover {
|
||||
background-color: #005bb5;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* DROPDOWN */
|
||||
/* dropdown container */
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* dropdown button styling */
|
||||
.dropdown-button {
|
||||
background-color: #6b006b;
|
||||
color: #e6e6ff;
|
||||
padding: 10px 20px;
|
||||
font-weight: bold;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.dropdown-button:hover {
|
||||
background-color: #aa00ff;
|
||||
}
|
||||
|
||||
/* dropdown content */
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background-color: #2e003e;
|
||||
min-width: 160px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
border-radius: 5px;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* links inside the dropdown */
|
||||
.dropdown-content a {
|
||||
color: #e6e6ff;
|
||||
padding: 10px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-content a:hover {
|
||||
background-color: #1a001a;
|
||||
color: #d4b0ff;
|
||||
}
|
||||
|
||||
/* show dropdown on button click */
|
||||
.dropdown:hover .dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/* global styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #1a001a;
|
||||
color: #e6e6ff;
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.noscroll {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.convcont {
|
||||
text-align: center;
|
||||
color: #b399ff; /* light purple color for visual separation */
|
||||
margin: 8px 0; /* vertical space around the separator */
|
||||
letter-spacing: 2px; /* spacing for the dots */
|
||||
opacity: 0.9;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
|
||||
#eof {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* styling for user links, e.g., @username */
|
||||
.user-link {
|
||||
color: #d4b0ff; /* matches the light purple in your theme */
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-link:hover {
|
||||
color: #b399ff; /* a slightly darker shade on hover */
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* styling for external links */
|
||||
.external-link {
|
||||
color: #ff66ff; /* a vibrant color for external links */
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.external-link:hover {
|
||||
color: #aa00ff; /* darker shade on hover */
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* container for the loading image */
|
||||
#loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #333; /* keep background color */
|
||||
z-index: 9999999999999999;
|
||||
}
|
||||
|
||||
/* loading image styling */
|
||||
#loading img {
|
||||
max-width: 50%;
|
||||
height: auto;
|
||||
/* responsive resizing */
|
||||
}
|
||||
|
||||
/* for small screens, adjust the loading image size */
|
||||
@media (max-width: 600px) {
|
||||
#loading img {
|
||||
max-width: 60%;
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
.dmselpopup {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80%;
|
||||
max-width: 600px;
|
||||
padding: 20px;
|
||||
background-color: #2c2540;
|
||||
border: 1px solid #3d3452;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
max-height: 80vh;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dmselpopup.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dmselpopup h2 {
|
||||
margin-top: 0;
|
||||
color: #b39ddb;
|
||||
}
|
||||
|
||||
.dm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #3d3452;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dm-item img {
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-right: 15px;
|
||||
border: 2px solid #6a5c96;
|
||||
}
|
||||
|
||||
.dm-item .info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dm-item .info .name {
|
||||
font-weight: bold;
|
||||
color: #e0e0ff;
|
||||
}
|
||||
|
||||
.dm-item .info .handle {
|
||||
color: #b39ddb;
|
||||
}
|
||||
|
||||
.dm-item.disabled {
|
||||
opacity: 0.5;
|
||||
color: #72678f;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
color: #e0e0ff;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #6a5c96;
|
||||
color: #e0e0ff;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #836aa8;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #9e7dc7;
|
||||
}
|
||||
|
||||
.dm-item.success {
|
||||
background-color: #2e4d2e;
|
||||
border: 1px solid #4caf50;
|
||||
color: #a5d6a7;
|
||||
}
|
||||
|
||||
.dm-item.success .info .name,
|
||||
.dm-item.success .info .handle {
|
||||
color: #a5d6a7;
|
||||
}
|
||||
|
||||
.dm-item.error {
|
||||
background-color: #4d2e2e;
|
||||
border: 1px solid #f44336;
|
||||
color: #ef9a9a;
|
||||
}
|
||||
|
||||
.dm-item.error .info .name,
|
||||
.dm-item.error .info .handle {
|
||||
color: #ef9a9a;
|
||||
}
|
||||
|
||||
/* message send success */
|
||||
.checkmark {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: #4caf50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.checkmark::after {
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 24px;
|
||||
border: solid white;
|
||||
border-width: 0 4px 4px 0;
|
||||
transform: rotate(45deg);
|
||||
animation: checkmark 0.3s ease-in-out forwards;
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
@keyframes checkmark {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(45deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* message send failure */
|
||||
.crossmark {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: #f44336;
|
||||
/* Red background for error */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.crossmark::before,
|
||||
.crossmark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 4px;
|
||||
background-color: white;
|
||||
border-radius: 2px;
|
||||
animation: crossmark 0.3s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* .crossmark::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.crossmark::after {
|
||||
transform: rotate(-45deg);
|
||||
} */
|
||||
|
||||
@keyframes crossmark {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.profile-container {
|
||||
width: 98%;
|
||||
margin: auto;
|
||||
/* max-width: 600px; */
|
||||
background-color: #2e003e;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0px 0px 15px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* banner section */
|
||||
.banner img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* profile content */
|
||||
.profile-content {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #aa00ff;
|
||||
margin-top: -50px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8em;
|
||||
margin: 10px 0;
|
||||
color: #d4b0ff;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.counts {
|
||||
margin: 15px 0;
|
||||
font-size: 0.9em;
|
||||
color: #b399ff;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #e6e6ff;
|
||||
background-color: #6b006b;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
background-color: #aa00ff;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/* container for each reply and its parent post, if any */
|
||||
.reply-card-container {
|
||||
background-color: #2a002e;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
color: #e6e6ff;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* preview of parent post (smaller font size for less emphasis) */
|
||||
.parent-post-preview, .grandparent-post-preview {
|
||||
background-color: #3d1c3d; /* distinct color for preview */
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* reply card styling */
|
||||
.reply-card {
|
||||
background-color: #2e003e;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* author section in reply */
|
||||
.reply-card .author-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* avatar styling */
|
||||
.reply-card .card-avatar {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
border: 2px solid #aa00ff;
|
||||
}
|
||||
|
||||
/* author info */
|
||||
.reply-card .author-info .author-name {
|
||||
font-size: 0.9em;
|
||||
color: #d4b0ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* reply text */
|
||||
.reply-card .reply-text {
|
||||
font-size: 0.9em;
|
||||
line-height: 1.3;
|
||||
color: #e6e6ff;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* embedded media */
|
||||
.reply-card .media-container {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reply-card .media-image {
|
||||
max-width: 80%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #aa00ff;
|
||||
margin-top: 6px;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* styles for the tab buttons */
|
||||
.tab-buttons {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
background-color: #1da1f2;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.tab-buttons button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.tab-buttons button.active {
|
||||
border-bottom: 3px solid white;
|
||||
}
|
||||
|
||||
/* section styling */
|
||||
.content {
|
||||
display: none;
|
||||
padding: 20px;
|
||||
background-color: #2e003e;
|
||||
}
|
||||
|
||||
.content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Hide the full-screen button from videos */
|
||||
/* video::-webkit-media-controls-fullscreen-button {
|
||||
display: none;
|
||||
} */
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/* basic styles for the video container and controls */
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.video-element {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 3px;
|
||||
/* background: rgba(0, 0, 0, 0.7); */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="range"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* style for the play/pause button */
|
||||
.play-pause {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* triangle icon for "Play" */
|
||||
.play-pause.play::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 12px solid white;
|
||||
border-top: 8px solid transparent;
|
||||
border-bottom: 8px solid transparent;
|
||||
}
|
||||
|
||||
/* double bars for "Pause" */
|
||||
.play-pause.pause::before,
|
||||
.play-pause.pause::after {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
display: inline-block;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
|
||||
/* fullscreen button styling */
|
||||
.fullscreen {
|
||||
font-size: 120%;
|
||||
color: white;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* enter fullscreen icon */
|
||||
.fullscreen.enter::before {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid white;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
/* exit fullscreen icon */
|
||||
.fullscreen.exit::before {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid white;
|
||||
border-bottom: none;
|
||||
border-right: none;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 50px;
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
/* palegoldenrod */
|
||||
accent-color: rgb(170, 10, 117);
|
||||
/* background: linear-gradient(90deg, #a200ff var(--progress), #1e9e04 var(--progress));
|
||||
-webkit-appearance: none;
|
||||
border: solid 1px #82CFD0;
|
||||
border-radius: 8px;
|
||||
transition: background 450ms ease-in; */
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.video-element {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 3px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="range"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.play-pause {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fullscreen {
|
||||
font-size: 120%;
|
||||
color: white;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.volume-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.volume-button {
|
||||
font-size: 18px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
|
||||
/* zoom container styles */
|
||||
.zoom-container {
|
||||
top: 5vh;
|
||||
position: fixed;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
max-height: 90vh;
|
||||
max-width: 90vw;
|
||||
cursor: pointer;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* initial image styling */
|
||||
.zoom-video, .zoom-image {
|
||||
max-height: 88vh;
|
||||
max-width: 88vw;
|
||||
transition: transform 0.5s ease, opacity 0.3s ease;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
border: solid 2px lightblue;
|
||||
}
|
||||
|
||||
/* styles for full-screen zoomed view */
|
||||
.zoom-container.zoomed .zoom-image, .zoom-container.zoomed .zoom-video {
|
||||
position: absolute;
|
||||
top: 5%;
|
||||
left: 0;
|
||||
width: auto;
|
||||
border-radius: 0; /* remove rounded corners when zoomed */
|
||||
box-shadow: 0px 8px 20px rgba(0, 0, 0, 0.6); /* shadow for zoom effect */
|
||||
z-index: 999999999;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
/* darken background when image is zoomed */
|
||||
.zoom-container.zoomed::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
/* width: 100vw;
|
||||
height: 100vh; */
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 999;
|
||||
transition: opacity 0.3s ease;
|
||||
opacity: 1;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
background-color: rgba(0, 0, 0, 0.336);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: 998;
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Profile Display</title>
|
||||
|
||||
<link rel="stylesheet" href="../CSS/global.css">
|
||||
<link rel="stylesheet" href="../CSS/loading.css">
|
||||
<link rel="stylesheet" href="../CSS/profile.css">
|
||||
<link rel="stylesheet" href="../CSS/tabs.css">
|
||||
<link rel="stylesheet" href="../CSS/cards.css">
|
||||
<link rel="stylesheet" href="../CSS/dropdown.css">
|
||||
<link rel="stylesheet" href="../CSS/zoom.css">
|
||||
<link rel="stylesheet" href="../CSS/replies.css">
|
||||
<link rel="stylesheet" href="../CSS/video.css">
|
||||
<link rel="stylesheet" href="../CSS/compose.css">
|
||||
<link rel="stylesheet" href="../CSS/post.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
|
||||
<script src="../JS/posts.cjs"></script>
|
||||
<script src="../JS/script.js"></script>
|
||||
<script src="../JS/compose.cjs"></script>
|
||||
<script src="../src/renderer.cjs"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="loading">
|
||||
<!-- sourced from https://xaydungso.vn/blog/20-mau-loading-gif-cute-dang-yeu-nhat-cho-website-cua-ban-en-in.html -->
|
||||
<img src="../assets/loading-transparent.gif">
|
||||
</div>
|
||||
<div id="imgzoom" class="zoom-container zoomed">
|
||||
<img src="" alt="Zoomable Image" class="zoom-image" />
|
||||
<video src="" alt="Zoomable VIdeo" class="zoom-video" controls></video>
|
||||
</div>
|
||||
<div class="overlay hidden"></div>
|
||||
|
||||
<div class="profile-container">
|
||||
<div class="banner">
|
||||
<img id="banner-img" src="" alt="Profile Banner">
|
||||
</div>
|
||||
<div class="profile-content">
|
||||
<img id="avatar-img" class="avatar" src="" alt="Avatar">
|
||||
<h1 id="handle">User Handle</h1>
|
||||
<p id="description">User description goes here.</p>
|
||||
<div class="counts">
|
||||
<span id="followersCount">100</span> Followers |
|
||||
<span id="followsCount">150</span> Following |
|
||||
<span id="postsCount">50</span> Posts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Buttons for Sections -->
|
||||
<div class="tab-buttons">
|
||||
<button id="postsBtn" class="active" onclick="showSection('posts')">Posts</button>
|
||||
<button id="repliesBtn" onclick="showSection('replies')">Replies</button>
|
||||
<button id="mediaBtn" onclick="showSection('media')">Media</button>
|
||||
<button id="likesBtn" onclick="showSection('likes')">Likes</button>
|
||||
<button id="bookmarksBtn" onclick="showSection('bookmarks')">Bookmarks</button>
|
||||
</div>
|
||||
|
||||
<!-- Section Content -->
|
||||
<div id="posts" class="content active">
|
||||
<p class="placeholder">nothing here...</p>
|
||||
<div class="dropdown">
|
||||
<button class="dropdown-button">Layout: Compact</button>
|
||||
<div class="dropdown-content" id="layoutDropdown">
|
||||
<a href="#" data-value="large">Large</a>
|
||||
<a href="#" data-value="relaxed">Relaxed</a>
|
||||
<a href="#" data-value="compact" class="selected">Compact</a>
|
||||
</div>
|
||||
</div>
|
||||
<button id="composebtn" class="compose-button">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
|
||||
<div id="overlay"></div>
|
||||
<div id="new-post-form" aria-labelledby="new-post-label">
|
||||
<form id="postForm">
|
||||
<label id="new-post-label" for="postContent">New Post:</label>
|
||||
<button type="button" id="closeButton" aria-label="Close form">✕</button>
|
||||
|
||||
<!-- main post content -->
|
||||
<textarea id="postContent" placeholder="What's on your mind?" aria-label="Post content"></textarea>
|
||||
|
||||
<!-- file upload button -->
|
||||
<button type="button" id="fileButton" aria-label="Upload a file">
|
||||
<i class="fa-regular fa-image"></i>
|
||||
</button>
|
||||
<input type="file" id="postFile" aria-label="Upload a file" style="display: none;" />
|
||||
|
||||
<!-- gif upload button -->
|
||||
<button type="button" id="gifButton" aria-label="Add a GIF">
|
||||
GIF
|
||||
</button>
|
||||
|
||||
<!-- audio upload button -->
|
||||
<button type="button" id="audioButton" aria-label="Upload audio">
|
||||
<i class="fa-solid fa-microphone"></i>
|
||||
</button>
|
||||
<input type="file" id="postAudio" accept="audio/*" aria-label="Upload an audio file"
|
||||
style="display: none;" />
|
||||
|
||||
<input type="text" id="postGif" placeholder="GIF URL" aria-label="Enter GIF URL"
|
||||
style="display: none;" />
|
||||
|
||||
<button id="showEmbedButton" type="button"><i class="fas fa-link"></i></button>
|
||||
|
||||
<div id="embedWidget" class="embed-widget">
|
||||
<label for="embedUri">URI:</label>
|
||||
<input type="url" id="embedUri" placeholder="https://example.com">
|
||||
|
||||
<label for="embedTitle">Title:</label>
|
||||
<input type="text" id="embedTitle" placeholder="Enter title">
|
||||
|
||||
<label for="embedDescription">Description:</label>
|
||||
<input type="text" id="embedDescription" placeholder="Enter description">
|
||||
|
||||
<button type="button" aria-label="Upload a file"
|
||||
onclick="document.querySelector('#embedImage').click()">
|
||||
<i class="fa-regular fa-image"></i>
|
||||
</button>
|
||||
<input type="file" id="embedImage" aria-label="Upload a file" style="display: none;" />
|
||||
|
||||
</div>
|
||||
|
||||
<div id="uploadStatus"></div>
|
||||
|
||||
<button type="submit">Post</button>
|
||||
</form>
|
||||
<p id="statusMessage" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div id="replies" class="content">
|
||||
<p class="placeholder">nothing here...</p>
|
||||
</div>
|
||||
<div id="media" class="content">
|
||||
<p class="placeholder">nothing here...</p>
|
||||
</div>
|
||||
<div id="likes" class="content">
|
||||
<p class="placeholder">nothing here...</p>
|
||||
</div>
|
||||
<div id="bookmarks" class="content">
|
||||
<p class="placeholder">nothing here...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dmselpopup" id="dmPopup">
|
||||
<button class="close-btn" onclick="document.querySelector('#dmPopup').classList.remove('active')">✖</button>
|
||||
<h2>Select a DM</h2>
|
||||
<div id="dmList"></div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
const { formatStr } = require('../src/renderer.cjs');
|
||||
|
||||
|
||||
/**
|
||||
* @param {Profile} profileData
|
||||
*/
|
||||
function populateProfile(profileData) {
|
||||
const bannerImg = document.querySelector('#banner-img');
|
||||
const avatarImg = document.querySelector('#avatar-img');
|
||||
const handleElement = document.querySelector('#handle');
|
||||
const descriptionElement = document.querySelector('#description');
|
||||
const followersCountElement = document.querySelector('#followersCount');
|
||||
const followsCountElement = document.querySelector('#followsCount');
|
||||
const postsCountElement = document.querySelector('#postsCount');
|
||||
|
||||
console.info(profileData);
|
||||
|
||||
// set element content
|
||||
bannerImg.src = profileData.banner;
|
||||
avatarImg.src = profileData.avatar;
|
||||
handleElement.textContent = profileData.handle;
|
||||
descriptionElement.innerHTML = formatStr(profileData.description);
|
||||
followersCountElement.textContent = profileData.followersCount;
|
||||
followsCountElement.textContent = profileData.followsCount;
|
||||
postsCountElement.textContent = profileData.postsCount;
|
||||
}
|
||||
|
||||
class Profile {
|
||||
constructor(data) {
|
||||
if (!data) throw "DATA NOT FOUND!";
|
||||
this.did = data.did,
|
||||
this.handle = data.handle,
|
||||
this.displayName = data.displayName,
|
||||
this.avatar = data.avatar,
|
||||
this.associated = data.associated,
|
||||
this.viewer = data.viewer,
|
||||
this.labels = data.labels,
|
||||
this.createdAt = data.createdAt,
|
||||
this.description = formatStr(data.description),
|
||||
this.indexedAt = data.indexedAt,
|
||||
this.banner = data.banner,
|
||||
this.followersCount = data.followersCount,
|
||||
this.followsCount = data.followsCount,
|
||||
this.postsCount = data.postsCount,
|
||||
this.pinnedPost = data.pinnedPost
|
||||
}
|
||||
|
||||
getProfileSummary() {
|
||||
return `${this.handle} (${this.did}): ${this.description}`;
|
||||
}
|
||||
|
||||
getPinnedPostUri() {
|
||||
return this.pinnedPost.uri;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = { Profile, populateProfile }
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
// set up the overlay and form toggling
|
||||
async function setup() {
|
||||
const composeButton = document.querySelector('#composebtn');
|
||||
const newPostForm = document.querySelector('#new-post-form');
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'overlay';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
function openForm() {
|
||||
newPostForm.classList.add("show");
|
||||
overlay.classList.add("show");
|
||||
}
|
||||
|
||||
composeButton.addEventListener('click', openForm);
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', setup);
|
||||
|
||||
async function renderCompose(ipcRenderer) {
|
||||
const overlay = document.querySelector("#overlay"),
|
||||
postForm = document.querySelector("#postForm"),
|
||||
statusMessage = document.querySelector("#statusMessage"),
|
||||
closeButton = document.querySelector("#closeButton");
|
||||
|
||||
const newPostForm = document.querySelector("#new-post-form");
|
||||
|
||||
// button and hidden input elements
|
||||
const fileButton = document.querySelector("#fileButton"),
|
||||
gifButton = document.querySelector("#gifButton"),
|
||||
audioButton = document.querySelector("#audioButton"),
|
||||
postFile = document.querySelector("#postFile"),
|
||||
postGif = document.querySelector("#postGif"),
|
||||
postAudio = document.querySelector("#postAudio"),
|
||||
postEmbed = document.getElementById('showEmbedButton'),
|
||||
embedWidget = document.querySelector('#embedWidget');
|
||||
|
||||
|
||||
function closeForm() {
|
||||
newPostForm.classList.remove("show");
|
||||
overlay.classList.remove("show");
|
||||
}
|
||||
|
||||
closeButton.addEventListener("click", closeForm);
|
||||
overlay.addEventListener("click", closeForm);
|
||||
|
||||
// trigger corresponding inputs when button is clicked
|
||||
fileButton.addEventListener("click", () => postFile.click());
|
||||
gifButton.addEventListener("click", () => {
|
||||
postGif.style.display = 'block';
|
||||
postGif.focus();
|
||||
});
|
||||
audioButton.addEventListener("click", () => postAudio.click());
|
||||
|
||||
postEmbed.addEventListener('click', () => {
|
||||
embedWidget.style.display = embedWidget.style.display === 'none' || embedWidget.style.display === '' ? 'block' : 'none';
|
||||
});
|
||||
|
||||
postForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const postContent = document.querySelector('#postContent').value.trim(),
|
||||
gifUrl = document.querySelector('#postGif').value.trim(),
|
||||
statusMessage = document.querySelector('#statusMessage'),
|
||||
embedData = Array.from(embedWidget.querySelectorAll('input')).map(o => {
|
||||
const id = o.id.replace('embed', '')
|
||||
if (o.type === 'file') return [id, o.files[0].name];
|
||||
else return [id, o.value];
|
||||
});
|
||||
|
||||
// files from hidden inputs
|
||||
const file = postFile.files[0];
|
||||
const audio = postAudio.files[0];
|
||||
|
||||
statusMessage.textContent = "Posting...";
|
||||
|
||||
try {
|
||||
ipcRenderer.invoke('new-post', JSON.stringify({ text: postContent, embed: Object.fromEntries(embedData) }));
|
||||
statusMessage.textContent = "Posted successfully!";
|
||||
postForm.reset();
|
||||
postGif.style.display = 'none'; // hide gif input after submission
|
||||
} catch (error) {
|
||||
console.error('Error posting:', error);
|
||||
statusMessage.textContent = 'Failed to post. Please try again.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function displayUploadStatus(files) {
|
||||
const statusContainer = document.querySelector('#uploadStatus'); // element to display results
|
||||
|
||||
// Clear previous status
|
||||
statusContainer.innerHTML = '';
|
||||
|
||||
// Iterate through files and display status
|
||||
for (const [fileName, fileStatus] of Object.entries(files)) {
|
||||
const statusMessage = document.createElement('p');
|
||||
|
||||
if (fileStatus) {
|
||||
// Upload succeeded
|
||||
statusMessage.textContent = `File "${fileName}" uploaded successfully!`;
|
||||
statusMessage.style.color = 'green';
|
||||
} else {
|
||||
// Upload failed
|
||||
statusMessage.textContent = `File "${fileName}" failed to upload!`;
|
||||
statusMessage.style.color = 'red';
|
||||
}
|
||||
|
||||
// Append status message to the container
|
||||
statusContainer.appendChild(statusMessage);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = { renderCompose, displayUploadStatus }
|
||||
@@ -0,0 +1,42 @@
|
||||
async function renderLikes(data, ipcRenderer) {
|
||||
let container;
|
||||
/** @type {Map<String, Element>} */
|
||||
let a;
|
||||
if (document.querySelector('#likescontainer')) {
|
||||
container = document.querySelector('#likescontainer');
|
||||
a = new Map(Array.from(container.querySelectorAll('.post-card')).map(o => ([o.dataset.bskyid, o])));
|
||||
}
|
||||
else {
|
||||
container = document.createElement('div');
|
||||
container.classList.add('cards-container');
|
||||
container.id = 'likescontainer';
|
||||
a = new Map();
|
||||
}
|
||||
|
||||
// setupWorker();
|
||||
// renderLikesFeed(posts.map(p => (p?.reply?.root?.uri || p.post.uri)?.trim()), likes, ipcRenderer);
|
||||
|
||||
for (const post of data) renderPostSingle(post, a, likes.map(o => ({ posturi: o.post.uri, likeuri: o.post.viewer?.like })), ipcRenderer, container);
|
||||
|
||||
const postids = Array.from(document.querySelectorAll('[data-like-bskyid]')).map(o => o.dataset.bskyid);
|
||||
console.log(postids);
|
||||
|
||||
// "force" garbage collection
|
||||
// delete a;
|
||||
a.clear();
|
||||
a = null;
|
||||
|
||||
// append container to the document body or a specific section
|
||||
const postdiv = document.querySelector('#posts');
|
||||
postdiv.querySelector('.placeholder')?.remove();
|
||||
if (!document.querySelector('#likescontainer')) {
|
||||
postdiv.appendChild(container);
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.tagName === "VIDEO") zoomvid(e);
|
||||
else if (e.target.tagName === "IMG" || e.target.classList.contains('overlay')) zoomimg(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = renderLikes;
|
||||
+638
@@ -0,0 +1,638 @@
|
||||
const { formatStr } = require("../src/renderer.cjs");
|
||||
|
||||
function zoomimg(e) {
|
||||
const { src } = e.target,
|
||||
overlayEl = document.querySelector('.overlay'),
|
||||
zoomContainer = document.querySelector('#imgzoom');
|
||||
|
||||
zoomContainer.querySelector('video').style.display = 'none';
|
||||
zoomContainer.querySelector('img').style.display = '';
|
||||
|
||||
zoomContainer.querySelector('img').src = src;
|
||||
zoomContainer.classList.toggle('zoomed');
|
||||
overlayEl.classList.toggle('hidden');
|
||||
document.querySelector('.profile-container').classList.toggle('.noscroll');
|
||||
}
|
||||
|
||||
function zoomvid(e) {
|
||||
const { src } = e.target;
|
||||
if (src.endsWith('/assets/video-loading.mp4')) return;
|
||||
|
||||
const zoomContainer = document.querySelector('#imgzoom');
|
||||
if (zoomContainer.contains(e.target)) return;
|
||||
|
||||
e.target.pause();
|
||||
|
||||
zoomContainer.querySelector('video').style.display = '';
|
||||
zoomContainer.querySelector('img').style.display = 'none';
|
||||
|
||||
zoomContainer.querySelector('video').src = src;
|
||||
zoomContainer.classList.toggle('zoomed');
|
||||
document.querySelector('.overlay').classList.toggle('hidden');
|
||||
document.querySelector('.profile-container').classList.toggle('.noscroll');
|
||||
|
||||
const isZoomed = zoomContainer.classList.contains('zoomed');
|
||||
e.target.parentElement.querySelector('.controls').style.display = (isZoomed) ? 'none !important' : ''
|
||||
}
|
||||
|
||||
|
||||
// video stuff
|
||||
function createVideoEl() {
|
||||
// create main video container
|
||||
const videoContainer = document.createElement('div');
|
||||
videoContainer.classList.add('video-container');
|
||||
videoContainer.classList.add('zoom-video');
|
||||
|
||||
// create video element
|
||||
const videoElement = document.createElement('video');
|
||||
videoElement.classList.add('video-element');
|
||||
videoElement.src = ''; // specify the video source here
|
||||
videoElement.controls = false; // disable default controls
|
||||
videoElement.setAttribute('playsinline', ''); // ensures mobile compatibility for inline playback
|
||||
|
||||
// create controls container
|
||||
const controls = document.createElement('div');
|
||||
controls.classList.add('controls');
|
||||
|
||||
// create play/pause button
|
||||
const playPauseButton = document.createElement('button');
|
||||
playPauseButton.classList.add('play-pause');
|
||||
playPauseButton.textContent = '';
|
||||
|
||||
// create fullscreen button
|
||||
const fullscreenButton = document.createElement('button');
|
||||
fullscreenButton.classList.add('fullscreen');
|
||||
fullscreenButton.textContent = '⛶';
|
||||
|
||||
// create progress bar
|
||||
const progressBar = document.createElement('input');
|
||||
progressBar.classList.add('progress-bar');
|
||||
progressBar.type = 'range';
|
||||
progressBar.min = '0';
|
||||
progressBar.value = '0';
|
||||
progressBar.step = '0.1';
|
||||
|
||||
// create volume button with slider container
|
||||
const volumeContainer = document.createElement('div');
|
||||
volumeContainer.classList.add('volume-container');
|
||||
|
||||
const volumeButton = document.createElement('button');
|
||||
volumeButton.classList.add('volume-button');
|
||||
volumeButton.textContent = '🔊';
|
||||
|
||||
// append volume control to volume container
|
||||
volumeContainer.appendChild(volumeButton);
|
||||
|
||||
// append controls to controls container
|
||||
controls.appendChild(playPauseButton);
|
||||
controls.appendChild(progressBar);
|
||||
controls.appendChild(volumeContainer); // add volume container to the interface
|
||||
controls.appendChild(fullscreenButton);
|
||||
|
||||
controls.style.display = 'none';
|
||||
|
||||
// append video and controls to video container
|
||||
videoContainer.appendChild(videoElement);
|
||||
videoContainer.appendChild(controls);
|
||||
|
||||
// JavaScript functionality for controls
|
||||
|
||||
// update play/pause functionality
|
||||
playPauseButton.addEventListener('click', () => {
|
||||
videoContainer.querySelector('.play-icon')?.remove();
|
||||
if (videoElement.paused || videoElement.ended) {
|
||||
videoElement.play();
|
||||
playPauseButton.classList.remove('play');
|
||||
playPauseButton.classList.add('pause');
|
||||
} else {
|
||||
videoElement.pause();
|
||||
playPauseButton.classList.remove('pause');
|
||||
playPauseButton.classList.add('play');
|
||||
}
|
||||
});
|
||||
fullscreenButton.onclick = (_) => videoElement.click();
|
||||
|
||||
// set initial icon state to "play"
|
||||
playPauseButton.classList.add('play');
|
||||
|
||||
// update progress bar as video plays
|
||||
videoElement.addEventListener('timeupdate', () => {
|
||||
progressBar.value = (videoElement.currentTime / videoElement.duration) * 100;
|
||||
});
|
||||
|
||||
videoElement.addEventListener('pause', () => {
|
||||
playPauseButton.classList.remove('pause');
|
||||
playPauseButton.classList.add('play');
|
||||
});
|
||||
|
||||
videoElement.addEventListener('play', () => {
|
||||
playPauseButton.classList.remove('play');
|
||||
playPauseButton.classList.add('pause');
|
||||
});
|
||||
|
||||
// allow user to skip within video by dragging progress bar
|
||||
progressBar.addEventListener('input', () => {
|
||||
videoElement.currentTime = (progressBar.value / 100) * videoElement.duration;
|
||||
});
|
||||
|
||||
// adjust video volume with volume control
|
||||
let preVol;
|
||||
volumeButton.addEventListener('click', () => {
|
||||
if (videoElement.volume) {
|
||||
preVol = videoElement.volume;
|
||||
videoElement.volume = '0';
|
||||
volumeButton.textContent = '🔇';
|
||||
}
|
||||
else {
|
||||
videoElement.volume = preVol || '1';
|
||||
volumeButton.textContent = '🔊';
|
||||
}
|
||||
});
|
||||
|
||||
return videoContainer;
|
||||
}
|
||||
|
||||
|
||||
function sendSuccess(dmItem, failed = false) {
|
||||
const image = dmItem.querySelector('img');
|
||||
if (image) {
|
||||
// Create the checkmark div
|
||||
const checkmark = document.createElement('div');
|
||||
checkmark.className = (failed) ? 'crossmark' : 'checkmark';
|
||||
checkmark.style.marginRight = '10px';
|
||||
|
||||
// Replace the image with the checkmark
|
||||
image.replaceWith(checkmark);
|
||||
|
||||
setTimeout(() => {
|
||||
document.querySelector('#dmPopup').querySelector('.close-btn').click();
|
||||
checkmark.replaceWith(image);
|
||||
checkmark.remove();
|
||||
}, 1500);
|
||||
}
|
||||
else console.log('no pfp found!');
|
||||
|
||||
dmItem.classList.add((failed) ? 'error' : 'success');
|
||||
}
|
||||
|
||||
|
||||
function renderDMs(posturi, follows, ipcRenderer) {
|
||||
const dmList = document.getElementById('dmList');
|
||||
dmList.innerHTML = '';
|
||||
|
||||
follows.forEach(follow => {
|
||||
const dmItem = document.createElement('div');
|
||||
dmItem.className = 'dm-item' + (follow.dm ? '' : ' disabled');
|
||||
dmItem.innerHTML = `
|
||||
<img src="${follow.avatar}" alt="${follow.displayName}">
|
||||
<div class="info">
|
||||
<div class="name">${follow.displayName || follow.handle}</div>
|
||||
<div class="handle">@${follow.handle}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
dmItem.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!follow.dm) return alert('not allowed!');
|
||||
|
||||
const r = await ipcRenderer.invoke('send-post', posturi, follow.handle, follow.dm.id);
|
||||
if (!r) sendSuccess(dmItem, true);
|
||||
else sendSuccess(dmItem);
|
||||
});
|
||||
|
||||
dmList.appendChild(dmItem);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function createButtonEls(card, ipcRenderer, idkey = 'bskyid', containerid = 'cards') {
|
||||
// Add this to your function after creating the main card content
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.classList.add('button-container');
|
||||
|
||||
// Like button
|
||||
const likeButton = document.createElement('button');
|
||||
likeButton.classList.add('action-button', 'like-button');
|
||||
likeButton.innerHTML = `<i class="fa-${card.dataset.likeuri ? 'solid' : 'regular'} fa-heart"></i>`;
|
||||
likeButton.title = 'Like';
|
||||
|
||||
// Like button
|
||||
const repostButton = document.createElement('button');
|
||||
repostButton.className = 'action-button';
|
||||
repostButton.style.color = card.dataset.reposturi ? '#10c200' : 'grey';
|
||||
repostButton.innerHTML = `<i class="fa-solid fa-retweet"></i>`;
|
||||
repostButton.title = 'Repost';
|
||||
|
||||
// Delete button
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.classList.add('action-button', 'delete-button');
|
||||
deleteButton.innerHTML = '<i class="fas fa-trash-alt"></i>';
|
||||
deleteButton.title = 'Delete';
|
||||
|
||||
// Send via DM button
|
||||
const sendDMButton = document.createElement('button');
|
||||
sendDMButton.classList.add('action-button', 'dm-button');
|
||||
sendDMButton.innerHTML = '<i class="fas fa-paper-plane"></i>';
|
||||
sendDMButton.title = 'Send via DM';
|
||||
|
||||
// Copy link button
|
||||
const copyLinkButton = document.createElement('button');
|
||||
copyLinkButton.classList.add('action-button', 'copy-link-button');
|
||||
copyLinkButton.innerHTML = '<i class="fas fa-link"></i>';
|
||||
copyLinkButton.title = 'Copy Link';
|
||||
|
||||
// Pin/Unpin toggle button
|
||||
const pinButton = document.createElement('button');
|
||||
pinButton.classList.add('action-button', 'pin-button');
|
||||
pinButton.innerHTML = '<i class="fas fa-thumbtack"></i>';
|
||||
pinButton.title = 'Pin/Unpin';
|
||||
|
||||
// Three-dot menu button to toggle dropdown
|
||||
const menuButton = document.createElement('button');
|
||||
menuButton.classList.add('action-button', 'menu-button');
|
||||
menuButton.innerHTML = '<i class="fas fa-ellipsis-h"></i>';
|
||||
menuButton.title = 'More options';
|
||||
|
||||
// Dropdown menu container for collapsible options
|
||||
const dropdownMenu = document.createElement('div');
|
||||
dropdownMenu.classList.add('dropdown-menu');
|
||||
dropdownMenu.classList.add('hidden'); // initially hidden
|
||||
|
||||
dropdownMenu.append(pinButton, copyLinkButton)
|
||||
|
||||
// Append buttons to the container
|
||||
buttonContainer.appendChild(likeButton);
|
||||
buttonContainer.appendChild(repostButton);
|
||||
buttonContainer.appendChild(deleteButton);
|
||||
buttonContainer.appendChild(sendDMButton);
|
||||
buttonContainer.appendChild(menuButton);
|
||||
buttonContainer.appendChild(dropdownMenu);
|
||||
|
||||
// Like button functionality
|
||||
likeButton.addEventListener('click', async () => {
|
||||
const lurl = card.dataset.likeuri,
|
||||
r = await ipcRenderer.invoke('post-action', 'like', card.dataset[`${idkey}`], lurl);
|
||||
|
||||
// if there is a url then it should return nothing
|
||||
if (!r && !lurl) return alert("ERROR!");
|
||||
else if (lurl) delete card.dataset.likeuri;
|
||||
else if (r) card.dataset.likeuri = r.uri
|
||||
likeButton.innerHTML = `<i class="fa-${lurl ? 'regular' : 'solid'} fa-heart"></i>`
|
||||
});
|
||||
|
||||
// Delete button functionality
|
||||
deleteButton.addEventListener('click', async () => {
|
||||
if (confirm('Are you sure you want to delete this post?')) {
|
||||
const r = await ipcRenderer.invoke('post-action', 'delete', card.dataset[`${idkey}`]);
|
||||
if (!r) return alert("ERROR!");
|
||||
card.remove();
|
||||
// alert('Post deleted.');
|
||||
}
|
||||
});
|
||||
|
||||
// Send DM functionality
|
||||
sendDMButton.addEventListener('click', async () => {
|
||||
const r = await ipcRenderer.invoke('get-connections');
|
||||
document.getElementById('dmPopup').classList.add('active');
|
||||
renderDMs(card.dataset[`${idkey}`], r.follows, ipcRenderer);
|
||||
});
|
||||
|
||||
// Copy link functionality
|
||||
copyLinkButton.addEventListener('click', async () => {
|
||||
const r = await ipcRenderer.invoke('post-action', 'link', card.dataset[`${idkey}`]);
|
||||
if (!r) return alert("ERROR!");
|
||||
navigator.clipboard.writeText(r).then(() => {
|
||||
alert('Link copied to clipboard!');
|
||||
});
|
||||
});
|
||||
|
||||
// Repost functionality
|
||||
repostButton.addEventListener('click', async () => {
|
||||
const r = await ipcRenderer.invoke('post-action', 'repost', card.dataset[`${idkey}`], card.dataset.reposturi);
|
||||
|
||||
if (!r) alert('ERROR!');
|
||||
else if (card.dataset.reposturi) {
|
||||
// TODO: move the card back to it's original position or refresh the page?
|
||||
if (!card.dataset.ismypost) card.remove();
|
||||
delete card.dataset.reposturi;
|
||||
repostButton.style.color = 'grey';
|
||||
}
|
||||
else {
|
||||
const pinnedEl = document.querySelector(`#${containerid}container`).querySelector('.pinned-card');
|
||||
if (pinnedEl) pinnedEl.after(card);
|
||||
else document.querySelector(`#${containerid}container`).prepend(card);
|
||||
|
||||
card.dataset.reposturi = r;
|
||||
repostButton.style.color = '#10c200';
|
||||
}
|
||||
});
|
||||
|
||||
// Pin/Unpin functionality
|
||||
let isPinned = false;
|
||||
pinButton.addEventListener('click', async () => {
|
||||
return alert("TODO");
|
||||
// const r = await ipcRenderer.invoke('post-action', 'pin', card.dataset.bskyid);
|
||||
// return alert(r);
|
||||
|
||||
isPinned = !isPinned;
|
||||
pinButton.title = isPinned ? 'Unpin' : 'Pin';
|
||||
pinButton.classList.toggle('pinned', isPinned);
|
||||
alert(isPinned ? 'Pinned!' : 'Unpinned!');
|
||||
// Add logic for pinning/unpinning the post
|
||||
});
|
||||
|
||||
menuButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // prevent event bubbling
|
||||
dropdownMenu.classList.toggle('hidden'); // show/hide menu
|
||||
});
|
||||
|
||||
// Hide dropdown menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!buttonContainer.contains(e.target)) {
|
||||
dropdownMenu.classList.add('hidden'); // hide menu
|
||||
}
|
||||
});
|
||||
|
||||
return buttonContainer;
|
||||
}
|
||||
|
||||
|
||||
function setupWorker(name = 'bktemp') {
|
||||
if (typeof (Worker) === "undefined") return console.error('workers not supported!\nswitching to manual...');
|
||||
|
||||
const w = new Worker("../JS/worker.js", { name });
|
||||
w.postMessage('PING');
|
||||
w.onmessage = function (event) {
|
||||
console.log(event.data);
|
||||
};
|
||||
|
||||
function stopWorker() {
|
||||
w.terminate();
|
||||
w = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} post
|
||||
* @param {*} a
|
||||
* @param {{posturi: string, likeuri: string}[]} likes
|
||||
* @param {*} ipcRenderer
|
||||
* @param {*} container
|
||||
*/
|
||||
function renderPostSingle(post, a, likes, ipcRenderer, container, idkey = 'bskyid', containerid) {
|
||||
/** @type {Element} */
|
||||
let card;
|
||||
const cardId = (post?.reply?.root?.uri || post.post.uri)?.trim();
|
||||
|
||||
// if the card exists, use it; otherwise, create a new card
|
||||
if (a.has(cardId)) card = a.get(cardId);
|
||||
else {
|
||||
card = document.createElement('div');
|
||||
card.classList.add('post-card');
|
||||
card.dataset[`${idkey}`] = cardId;
|
||||
}
|
||||
|
||||
// handle repost reason if it hasn't been added to this card
|
||||
if (post.reason && post.reason.by && !card.querySelector('.repost-section')) {
|
||||
const aname = post.reason.by.displayName || formatStr(post.reason.by.handle);
|
||||
const repostSection = document.createElement('div');
|
||||
repostSection.classList.add('repost-section');
|
||||
|
||||
const repostAvatar = document.createElement('img');
|
||||
repostAvatar.src = post.reason.by.avatar;
|
||||
repostAvatar.alt = `${aname}'s avatar`;
|
||||
repostAvatar.classList.add('card-avatar', 'repost-avatar');
|
||||
repostAvatar.loading = 'lazy';
|
||||
|
||||
const repostInfo = document.createElement('div');
|
||||
repostInfo.classList.add('repost-info');
|
||||
|
||||
const repostName = document.createElement('h2');
|
||||
repostName.innerHTML = aname;
|
||||
repostName.classList.add('author-name');
|
||||
|
||||
const repostHandle = document.createElement('p');
|
||||
repostHandle.innerHTML = `${formatStr('@' + post.reason.by.handle)} reposted`;
|
||||
repostHandle.classList.add('author-handle');
|
||||
|
||||
repostInfo.appendChild(repostName);
|
||||
repostInfo.appendChild(repostHandle);
|
||||
repostSection.appendChild(repostAvatar);
|
||||
repostSection.appendChild(repostInfo);
|
||||
|
||||
card.dataset.ismypost = post.post.author.did === post.reason.by.did;
|
||||
card.dataset.reposturi = post.post.viewer.repost;
|
||||
card.prepend(repostSection);
|
||||
}
|
||||
|
||||
// handle reply if it hasn't been added to this card
|
||||
if (post.reply && post.reply.root && !card.querySelector('.original-post')) {
|
||||
const originalPost = document.createElement('div');
|
||||
originalPost.classList.add('original-post');
|
||||
|
||||
const repaname = post.reply.root.author.displayName || formatStr(post.reply.root.author?.handle);
|
||||
// add the original author and text
|
||||
const originalAuthor = document.createElement('p');
|
||||
originalAuthor.innerHTML = `Replying to ${repaname}`;
|
||||
originalAuthor.classList.add('original-author');
|
||||
originalPost.appendChild(originalAuthor);
|
||||
|
||||
const originalText = document.createElement('p');
|
||||
originalText.innerHTML = formatStr(post.reply.root.record.text) || 'Image reply';
|
||||
originalText.classList.add('original-text');
|
||||
originalPost.appendChild(originalText);
|
||||
|
||||
// add the original image if present
|
||||
if (post.reply.root.embed?.images?.length > 0) {
|
||||
const originalImage = document.createElement('img');
|
||||
originalImage.loading = 'lazy';
|
||||
originalImage.src = post.reply.root.embed.images[0].thumb;
|
||||
originalImage.alt = post.reply.root.embed.images[0].alt || 'Original post image';
|
||||
originalImage.classList.add('original-image');
|
||||
originalPost.appendChild(originalImage);
|
||||
}
|
||||
|
||||
card.appendChild(originalPost);
|
||||
|
||||
// add the section for "what I was replying to" if grandparentAuthor exists
|
||||
if (post.reply.grandparentAuthor) {
|
||||
const parentPost = document.createElement('div');
|
||||
parentPost.classList.add('parent-post');
|
||||
|
||||
const spacingEl = document.createElement('p');
|
||||
spacingEl.className = 'convcont';
|
||||
spacingEl.textContent = '.....';
|
||||
|
||||
const parentAuthor = document.createElement('p');
|
||||
const paraname = post.reply.parent.author.displayName || formatStr(post.reply.parent.author.handle);
|
||||
parentAuthor.innerHTML = `${paraname}`;
|
||||
parentAuthor.classList.add('original-author');
|
||||
|
||||
parentPost.append(spacingEl, parentAuthor);
|
||||
|
||||
const parentText = document.createElement('p');
|
||||
parentText.innerHTML = formatStr(post.reply.parent.record.text) || 'Image reply';
|
||||
parentText.classList.add('original-text');
|
||||
parentPost.appendChild(parentText);
|
||||
|
||||
if (post.reply.parent.embed && post.reply.parent.embed.images && post.reply.parent.embed.images.length > 0) {
|
||||
const parentImage = document.createElement('img');
|
||||
parentImage.loading = 'lazy';
|
||||
parentImage.src = post.reply.parent.embed.images[0].thumb;
|
||||
parentImage.alt = post.reply.parent.embed.images[0].alt || 'Parent post image';
|
||||
parentImage.classList.add('original-image');
|
||||
parentPost.appendChild(parentImage);
|
||||
}
|
||||
|
||||
card.appendChild(parentPost);
|
||||
}
|
||||
}
|
||||
|
||||
// author info section for the main post
|
||||
if (!card.querySelector('.author-section')) {
|
||||
const aname = post.post.author.displayName || formatStr(post.post.author.handle);
|
||||
const authorSection = document.createElement('div');
|
||||
authorSection.classList.add('author-section');
|
||||
|
||||
const avatar = document.createElement('img');
|
||||
avatar.src = post.post.author.avatar;
|
||||
avatar.alt = `${aname}'s avatar`;
|
||||
avatar.classList.add('card-avatar');
|
||||
avatar.loading = 'lazy';
|
||||
|
||||
const authorInfo = document.createElement('div');
|
||||
authorInfo.classList.add('author-info');
|
||||
|
||||
const displayName = document.createElement('h2');
|
||||
displayName.innerHTML = aname;
|
||||
displayName.classList.add('author-name');
|
||||
|
||||
const handle = document.createElement('p');
|
||||
handle.innerHTML = `${formatStr('@' + post.post.author.handle)}`;
|
||||
handle.classList.add('author-handle');
|
||||
|
||||
authorInfo.appendChild(displayName);
|
||||
authorInfo.appendChild(handle);
|
||||
authorSection.appendChild(avatar);
|
||||
authorSection.appendChild(authorInfo);
|
||||
|
||||
card.appendChild(authorSection);
|
||||
}
|
||||
|
||||
// main post text content
|
||||
if (!card.querySelector('.post-text')) {
|
||||
const textContent = document.createElement('p');
|
||||
textContent.innerHTML = formatStr(post.post.record.text);
|
||||
textContent.classList.add('post-text');
|
||||
card.appendChild(textContent);
|
||||
}
|
||||
|
||||
// handle video embed (if present and not added yet)
|
||||
if (post.post.embed && post.post.embed.$type === 'app.bsky.embed.video#view' && !card.querySelector('.post-video')) {
|
||||
const videoContainer = createVideoEl(), videoPlayer = videoContainer.querySelector('video');
|
||||
|
||||
videoPlayer.classList.add('post-video');
|
||||
videoPlayer.width = post.post.embed.aspectRatio?.width;
|
||||
videoPlayer.height = post.post.embed.aspectRatio?.height;
|
||||
videoContainer.appendChild(videoPlayer);
|
||||
card.appendChild(videoContainer);
|
||||
videoPlayer.src = '../assets/video-loading.mp4';
|
||||
videoPlayer.dataset.src = post.post.embed.playlist;
|
||||
videoPlayer.poster = post.post.embed.thumbnail;
|
||||
}
|
||||
|
||||
// main post image (if it exists and hasn't been added)
|
||||
if (post.post.embed && post.post.embed.images && post.post.embed.images.length > 0 && !card.querySelector('.post-image')) {
|
||||
const repeatimg = card.querySelector('.original-image')?.src === post.post.embed.images[0].thumb;
|
||||
if (!repeatimg) {
|
||||
const imageContainer = document.createElement('div');
|
||||
imageContainer.classList.add('image-container');
|
||||
|
||||
const postImage = document.createElement('img');
|
||||
postImage.src = post.post.embed.images[0].thumb;
|
||||
postImage.alt = post.post.embed.images[0].alt || 'Embedded image';
|
||||
postImage.classList.add('post-image');
|
||||
postImage.loading = 'lazy';
|
||||
|
||||
imageContainer.appendChild(postImage);
|
||||
card.appendChild(imageContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// interaction counts
|
||||
if (!card.querySelector('.interaction-counts')) {
|
||||
const counts = document.createElement('div');
|
||||
counts.classList.add('interaction-counts');
|
||||
counts.textContent = `💬 ${post.post.replyCount} 🔄 ${post.post.repostCount} ❤️ ${post.post.likeCount}`;
|
||||
card.appendChild(counts);
|
||||
}
|
||||
|
||||
// add the card to the container if it's a new card
|
||||
if (!a.has(cardId)) {
|
||||
const lurl = likes.find(o => (o.posturi === cardId));
|
||||
if (lurl) card.dataset.likeuri = lurl.likeuri;
|
||||
card.appendChild(createButtonEls(card, ipcRenderer, idkey, containerid));
|
||||
container.appendChild(card);
|
||||
a.set(cardId, card);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {*} posts
|
||||
* @param {*} likes
|
||||
* @param {*} pinnedPost
|
||||
* @param {*} ipcRenderer
|
||||
*/
|
||||
module.exports = function renderPosts(posts, likes, pinnedPost, ipcRenderer, idkey = 'bskyid', containerid = 'posts') {
|
||||
console.log(`${idkey}, ${containerid}container, ${posts.length}`);
|
||||
let container;
|
||||
/** @type {Map<String, Element>} */
|
||||
let a;
|
||||
if (document.querySelector(`#${containerid}container`)) {
|
||||
container = document.querySelector(`#${containerid}container`);
|
||||
a = new Map(Array.from(container.querySelectorAll('.post-card')).map(o => ([o.dataset[`${idkey}`], o])));
|
||||
}
|
||||
else {
|
||||
container = document.createElement('div');
|
||||
container.classList.add('cards-container');
|
||||
container.id = containerid;
|
||||
a = new Map();
|
||||
}
|
||||
|
||||
// setupWorker();
|
||||
// renderLikesFeed(posts.map(p => (p?.reply?.root?.uri || p.post.uri)?.trim()), likes, ipcRenderer);
|
||||
|
||||
for (const post of posts) renderPostSingle(post, a, likes.map(o => ({ posturi: o.post.uri, likeuri: o.post.viewer?.like })), ipcRenderer, container, idkey, containerid);
|
||||
|
||||
const postids = Array.from(document.querySelectorAll(`[data-${idkey}]`)).map(o => o.dataset[`${idkey} `]);
|
||||
console.log(postids);
|
||||
|
||||
// "force" garbage collection
|
||||
// delete a;
|
||||
a.clear();
|
||||
a = null;
|
||||
|
||||
if (pinnedPost) {
|
||||
const pinnedcard = container.querySelector(`[data-${idkey}="${pinnedPost.uri}"]`);
|
||||
if (pinnedcard) {
|
||||
const pinnedLabel = document.createElement('div');
|
||||
pinnedLabel.classList.add('pinned-label');
|
||||
pinnedLabel.textContent = '📌 Pinned'; // optionally add an emoji/icon
|
||||
pinnedcard?.prepend(pinnedLabel); // place it at the top of the pinned card
|
||||
pinnedcard?.classList.add('pinned-card');
|
||||
container.prepend(pinnedcard); // move it to be first
|
||||
}
|
||||
}
|
||||
|
||||
// append container to the document body or a specific section
|
||||
const postdiv = document.getElementById(containerid);
|
||||
postdiv.querySelector('.placeholder')?.remove();
|
||||
if (!document.querySelector(`#${containerid}container`)) {
|
||||
postdiv.appendChild(container);
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.tagName === "VIDEO") zoomvid(e);
|
||||
else if (e.target.tagName === "IMG" || e.target.classList.contains('overlay')) zoomimg(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
// function to create a single reply card, with an optional parent post shown above it
|
||||
function createReplyCard(replyData) {
|
||||
const replyCardContainer = document.createElement('div');
|
||||
replyCardContainer.classList.add('reply-card-container');
|
||||
|
||||
// add parent post if exists, along with dots if it's not the root
|
||||
if (replyData.reply.parent) {
|
||||
const parentPost = createPostPreview(replyData.reply.parent, true);
|
||||
replyCardContainer.appendChild(parentPost);
|
||||
|
||||
// add dots if there's a grandparent (to indicate more context above)
|
||||
if (replyData.reply.grandparentAuthor) {
|
||||
const dots = document.createElement('p');
|
||||
dots.classList.add('convcont');
|
||||
dots.textContent = '.....';
|
||||
replyCardContainer.appendChild(dots);
|
||||
}
|
||||
}
|
||||
|
||||
// main reply card
|
||||
const replyCard = document.createElement('div');
|
||||
replyCard.classList.add('reply-card');
|
||||
|
||||
// author section
|
||||
const authorSection = document.createElement('div');
|
||||
authorSection.classList.add('author-section');
|
||||
|
||||
const avatar = document.createElement('img');
|
||||
avatar.src = replyData.post.author.avatar;
|
||||
avatar.alt = `${replyData.post.author.handle}'s avatar`;
|
||||
avatar.classList.add('card-avatar');
|
||||
authorSection.appendChild(avatar);
|
||||
|
||||
const authorInfo = document.createElement('div');
|
||||
authorInfo.classList.add('author-info');
|
||||
const authorName = document.createElement('p');
|
||||
authorName.textContent = replyData.post.author.displayName || replyData.post.author.handle;
|
||||
authorName.classList.add('author-name');
|
||||
authorInfo.appendChild(authorName);
|
||||
|
||||
authorSection.appendChild(authorInfo);
|
||||
replyCard.appendChild(authorSection);
|
||||
|
||||
// reply text
|
||||
const replyText = document.createElement('p');
|
||||
replyText.textContent = replyData.post.record.text || 'Image reply';
|
||||
replyText.classList.add('reply-text');
|
||||
replyCard.appendChild(replyText);
|
||||
|
||||
// embedded media if available
|
||||
if (replyData.post.embed && replyData.post.embed.external) {
|
||||
const mediaContainer = document.createElement('div');
|
||||
mediaContainer.classList.add('media-container');
|
||||
|
||||
const mediaImage = document.createElement('img');
|
||||
mediaImage.src = replyData.post.embed.external.thumb;
|
||||
mediaImage.alt = replyData.post.embed.external.description;
|
||||
mediaImage.classList.add('media-image');
|
||||
mediaImage.loading = 'lazy';
|
||||
|
||||
mediaContainer.appendChild(mediaImage);
|
||||
replyCard.appendChild(mediaContainer);
|
||||
}
|
||||
|
||||
replyCardContainer.appendChild(replyCard);
|
||||
return replyCardContainer;
|
||||
}
|
||||
|
||||
// helper function to create a condensed preview of the parent post
|
||||
function createPostPreview(post, isParent = false) {
|
||||
const postPreview = document.createElement('div');
|
||||
postPreview.classList.add(isParent ? 'parent-post-preview' : 'grandparent-post-preview');
|
||||
|
||||
// author and content preview
|
||||
const author = document.createElement('p');
|
||||
author.textContent = `${post.author.displayName || post.author.handle}`;
|
||||
author.classList.add('original-author');
|
||||
postPreview.appendChild(author);
|
||||
|
||||
const content = document.createElement('p');
|
||||
content.textContent = post.record.text || 'Image reply';
|
||||
content.classList.add('original-text');
|
||||
postPreview.appendChild(content);
|
||||
|
||||
return postPreview;
|
||||
}
|
||||
|
||||
|
||||
module.exports = function renderReplies(replyObj) {
|
||||
const repliesContainer = document.querySelector('#replies');
|
||||
repliesContainer.querySelector('.placeholder')?.remove();
|
||||
|
||||
const { cursor, replies } = replyObj;
|
||||
|
||||
if (cursor) sessionStorage.setItem('repliescursor', cursor);
|
||||
else sessionStorage.removeItem('repliescursor');
|
||||
|
||||
replies.forEach(reply => {
|
||||
const replyCard = createReplyCard(reply);
|
||||
repliesContainer.appendChild(replyCard);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
document.addEventListener('scroll', () => bottomscrolled(document.querySelector('#posts')));
|
||||
async function bottomscrolled(targetDiv) {
|
||||
const scrollPosition = window.scrollY + window.innerHeight,
|
||||
pageHeight = document.documentElement.scrollHeight,
|
||||
atbottom = (scrollPosition >= pageHeight);
|
||||
|
||||
if (!atbottom) return;
|
||||
|
||||
window.electronAPI.getnewposts();
|
||||
}
|
||||
|
||||
// function to toggle the active section
|
||||
function showSection(sectionId) {
|
||||
// hide all sections
|
||||
document.querySelectorAll('.content').forEach((content) => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
// remove active class from all buttons
|
||||
document.querySelectorAll('.tab-buttons button').forEach((button) => {
|
||||
button.classList.remove('active');
|
||||
});
|
||||
|
||||
// show the selected section and activate the button
|
||||
document.getElementById(sectionId).classList.add('active');
|
||||
document.getElementById(sectionId + 'Btn').classList.add('active');
|
||||
}
|
||||
|
||||
// function to handle layout selection
|
||||
function togglerelaxed(e) {
|
||||
e.preventDefault(); // prevent the default anchor behavior
|
||||
|
||||
const value = e.target.getAttribute('data-value');
|
||||
const container = document.querySelector(`#${sessionStorage.getItem('currenttab') || 'cardscontainer'}`);
|
||||
|
||||
console.log(value)
|
||||
|
||||
if (!container) return console.warn('container not found!');
|
||||
|
||||
if (value === 'compact') {
|
||||
container.classList.remove('cards-container-relaxed');
|
||||
container.classList.add('cards-container');
|
||||
} else {
|
||||
container.style.gridTemplateColumns = (value !== 'large') ? 'repeat(auto-fill, minmax(280px, 1fr))' : '';
|
||||
container.classList.remove('cards-container');
|
||||
container.classList.add('cards-container-relaxed');
|
||||
}
|
||||
|
||||
// update dropdown button text to reflect current selection
|
||||
document.querySelector('.dropdown-button').textContent = `Layout: ${value.charAt(0).toUpperCase() + value.slice(1)}`;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// event listener for dropdown items
|
||||
document.querySelectorAll('#layoutDropdown a').forEach(item => {
|
||||
item.addEventListener('click', togglerelaxed);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
// listen for messages from the main thread
|
||||
self.onmessage = function (event) {
|
||||
const data = event.data;
|
||||
|
||||
self.postMessage(document.querySelector('#posts')?.firstChild)
|
||||
|
||||
// send the result back to the main thread
|
||||
self.postMessage(result);
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
@@ -0,0 +1,130 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ChatBskyConvoDefs } from "@atproto/api";
|
||||
import { convoHeader, getDID, initSession } from "./main.js";
|
||||
|
||||
|
||||
|
||||
export async function getDMs(cursor = undefined, limit = undefined) {
|
||||
try {
|
||||
const agent = await initSession(),
|
||||
convos = await agent.chat.bsky.convo.listConvos({ cursor, limit });
|
||||
return convos.data;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function sendPost(e, posturi, utag, dmid = undefined) {
|
||||
try {
|
||||
const agent = await initSession(),
|
||||
[, did, collection, rkey] = posturi.match(/^at:\/\/([^\/]+)\/([^\/]+)\/(.+)$/),
|
||||
post = await agent.getPost({ repo: did, rkey, collection }),
|
||||
|
||||
/** @type {ChatBskyConvoDefs.MessageInput} */
|
||||
msg = {
|
||||
embed: {
|
||||
$type: 'app.bsky.embed.record',
|
||||
record: {
|
||||
cid: post.cid,
|
||||
uri: post.uri
|
||||
}
|
||||
},
|
||||
text: ''
|
||||
}
|
||||
|
||||
return await sendMessage(utag, msg, dmid);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function sendMessage(utag, message, convoIdInp = undefined) {
|
||||
try {
|
||||
const agent = await initSession();
|
||||
let convoId;
|
||||
|
||||
if (convoIdInp) convoId = convoIdInp;
|
||||
else {
|
||||
const did = await getDID(utag);
|
||||
if (!did) return null;
|
||||
|
||||
const convos = (await getDMs()).convos;
|
||||
convoId = await agent.chat.bsky.convo.getConvo({ convoId: convos.find(o => o.members.find(o => o.did === did)) });
|
||||
}
|
||||
return (await agent.chat.bsky.convo.sendMessage({ convoId, message }, { headers: convoHeader })).data;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { dialog, ipcMain } from "electron";
|
||||
import { PassThrough, Readable } from 'stream';
|
||||
import { initSession } from "./main.js";
|
||||
import sharp from "sharp";
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import fs from 'fs';
|
||||
|
||||
|
||||
const fnamesToPosts = {};
|
||||
|
||||
// Store data in main process only
|
||||
ipcMain.handle('set-sensitive-data', (event, key, value) => {
|
||||
fnamesToPosts[key] = value;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-files', (event, key) => {
|
||||
return JSON.stringify(fnamesToPosts);
|
||||
});
|
||||
|
||||
export const clearPostFiles = () => {
|
||||
for (const key in fnamesToPosts) {
|
||||
delete fnamesToPosts[key];
|
||||
}
|
||||
}
|
||||
export const getPostFiles = () => Object.entries(fnamesToPosts);
|
||||
export const popPostFile = (fname) => {
|
||||
if (!fname || !(fname in fnamesToPosts)) return;
|
||||
const o = fnamesToPosts[fname];
|
||||
delete fnamesToPosts[fname];
|
||||
return o;
|
||||
}
|
||||
|
||||
ipcMain.handle('clear-sensitive-data', clearPostFiles);
|
||||
|
||||
export const isAnimated = (mimeType) => (mimeType.startsWith('video/') || mimeType.endsWith('/gif'));
|
||||
|
||||
|
||||
function resizeVideo(data, type) {
|
||||
// normal video, do not convert
|
||||
if (!type.endsWith('/gif')) {
|
||||
const readableStream = new Readable();
|
||||
readableStream.push(data);
|
||||
readableStream.push(null);
|
||||
return readableStream;
|
||||
}
|
||||
|
||||
const passThroughStream = new PassThrough();
|
||||
ffmpeg(data)
|
||||
// .size(`${width}x?`) // Resize to specified width, keeping aspect ratio
|
||||
.outputOptions('-c:v libx264', '-crf 28') // Set codec and compression level
|
||||
.format('mp4') // Set format to MP4
|
||||
.on('error', (err) => {
|
||||
console.error('Error processing video:', err);
|
||||
passThroughStream.destroy(err);
|
||||
})
|
||||
.on('end', () => {
|
||||
console.log('Video processing completed.');
|
||||
})
|
||||
.pipe(passThroughStream, { end: true });
|
||||
|
||||
return passThroughStream;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function resizePhoto(data, type) {
|
||||
const outputStream = new PassThrough();
|
||||
sharp(data)
|
||||
.resize({ width: 800 }) // adjust width to reduce size REMOVEME?
|
||||
.toFormat(type)
|
||||
.pipe(outputStream);
|
||||
|
||||
return outputStream;
|
||||
}
|
||||
|
||||
|
||||
export async function uploadFile(fobj) {
|
||||
// bluesky does not support gifs, so EVERYTHING needs to be an mp4
|
||||
const agent = await initSession(),
|
||||
anim = isAnimated(fobj.type),
|
||||
type = anim ? 'mp4' : fobj.type.split('/')?.at(1),
|
||||
typefull = anim ? 'video/mp4' : fobj.type;
|
||||
|
||||
let outputStream;
|
||||
|
||||
if (anim) outputStream = resizeVideo(fobj.data, fobj.type);
|
||||
else outputStream = resizePhoto(fobj.data, type);
|
||||
|
||||
// not reading through a stream ffs
|
||||
// if (compressedBuffer.length > 976 * 1024) {
|
||||
// console.error("File is still too large after compression. Try further resizing.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
// upload the stream as a blob
|
||||
const { data } = await agent.uploadBlob(outputStream, { encoding: typefull });
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {...{name: string, type: string, size: number, data: Uint8Array}} fobjs
|
||||
*/
|
||||
export async function handleFileOpen(event, ...fobjs) {
|
||||
const o = {};
|
||||
|
||||
for (const fobj of fobjs) {
|
||||
try {
|
||||
const blobData = await uploadFile(fobj);
|
||||
o[fobj.name] = blobData.blob.ref;
|
||||
fnamesToPosts[fobj.name] = blobData.blob;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
o[fobj.name] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(o);
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import { Agent, AppBskyFeedGenerator, CredentialSession } from '@atproto/api';
|
||||
import fs from 'fs';
|
||||
import * as crypto from 'crypto';
|
||||
import json from '../secrets/config.json' with { type: 'json' }
|
||||
import { ipcMain } from 'electron';
|
||||
import logger from '../logger.js';
|
||||
import { getHistory } from '../src/db.js';
|
||||
import convertAndServe, { clearCache } from './video.js';
|
||||
import { handleFileOpen } from './files.js';
|
||||
import post, { handlePostAction } from './post.js';
|
||||
import { sendPost } from './convoManager.js';
|
||||
|
||||
const { uname, upass } = json.bluesky;
|
||||
const sessionFilePath = './secrets/session.json'; // path to your session file
|
||||
export const convoHeader = { "Atproto-Proxy": "did:web:api.bsky.chat#bsky_chat" };
|
||||
|
||||
|
||||
// function to load session data from the file
|
||||
function loadSession() {
|
||||
try {
|
||||
if (fs.existsSync(sessionFilePath)) {
|
||||
const data = fs.readFileSync(sessionFilePath, 'utf-8');
|
||||
logger.info('session loaded successfully');
|
||||
return JSON.parse(data);
|
||||
}
|
||||
logger.info('no existing session found');
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('failed to load session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// create a Bluesky session and agent
|
||||
const session = new CredentialSession(new URL('https://bsky.social'));
|
||||
const agent = new Agent(session);
|
||||
|
||||
// function to save session data to a file, with validation
|
||||
function saveSession(data) {
|
||||
if (!data) {
|
||||
logger.error('No session data to save.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(sessionFilePath, JSON.stringify(data), 'utf-8');
|
||||
logger.info('session saved successfully');
|
||||
} catch (error) {
|
||||
logger.error('failed to save session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// initialize and resume session if possible
|
||||
export async function initSession() {
|
||||
if (session?.session?.active) return agent;
|
||||
|
||||
const savedSession = loadSession();
|
||||
if (savedSession) {
|
||||
try {
|
||||
await session.resumeSession(savedSession);
|
||||
logger.info('session resumed successfully');
|
||||
} catch (resumeError) {
|
||||
logger.warn('failed to resume session, attempting to refresh:', resumeError);
|
||||
try {
|
||||
await session.refreshSession();
|
||||
if (session.session) {
|
||||
saveSession(session.session); // ensure session data exists before saving
|
||||
logger.info('session refreshed and saved successfully');
|
||||
} else {
|
||||
logger.error('refresh failed to retrieve valid session data.');
|
||||
await loginAndSaveSession();
|
||||
}
|
||||
} catch (refreshError) {
|
||||
logger.error('session refresh failed, logging in again:', refreshError);
|
||||
await loginAndSaveSession();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await loginAndSaveSession();
|
||||
}
|
||||
|
||||
// await agent.deleteRepost('at://did:plc:amhzdnxsvkcqjgwdh5kqmhk7/app.bsky.feed.post/3l6tg2zdph62n');
|
||||
return agent;
|
||||
}
|
||||
|
||||
// helper function to login and save the session
|
||||
async function loginAndSaveSession() {
|
||||
try {
|
||||
await session.login({ identifier: uname, password: upass });
|
||||
if (session.session) {
|
||||
saveSession(session.session);
|
||||
logger.info('logged in and session saved');
|
||||
} else {
|
||||
logger.error('login succeeded but no session data available to save');
|
||||
}
|
||||
} catch (loginError) {
|
||||
logger.error('login failed:', loginError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const getDID = async (utag) => {
|
||||
try {
|
||||
let did;
|
||||
if (!utag || utag === '@me') {
|
||||
did = session.did || session?.session?.did;
|
||||
} else {
|
||||
const resolved = await agent.resolveHandle({ handle: utag });
|
||||
did = resolved.data.did;
|
||||
}
|
||||
return did;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function getUserData(utag, allData = false) {
|
||||
try {
|
||||
await initSession(); // ensure session is fully initialized before proceeding
|
||||
const did = await getDID(utag);
|
||||
if (!did) return { err: 'DID not found!' };
|
||||
|
||||
const { data } = await agent.getProfile({ actor: did });
|
||||
const output = { profile: data };
|
||||
|
||||
if (allData) {
|
||||
const { data: { feed, cursor } } = await agent.getAuthorFeed({ actor: output.profile.did, limit: 20, includePins: true }),
|
||||
{ data: { feed: likes, cursor: likesCursor } } = await agent.getActorLikes({ actor: did });
|
||||
|
||||
output.likes = likes;
|
||||
output.likesCursor = likesCursor;
|
||||
output.posts = feed;
|
||||
output.postcursor = cursor;
|
||||
|
||||
output.replies = await getReplies(utag);
|
||||
}
|
||||
|
||||
return output;
|
||||
} catch (err) {
|
||||
logger.error('failed to fetch user data:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const getPosts = async (utag, cursor = undefined, likesCursor = undefined) => {
|
||||
try {
|
||||
const did = await getDID(utag);
|
||||
if (!did) return { err: 'DID not found!' };
|
||||
|
||||
const likes = await agent.getActorLikes({ actor: did, cursor: likesCursor }),
|
||||
posts = await agent.getAuthorFeed({ actor: did, limit: 20, includePins: true, cursor });
|
||||
return { posts, likes };
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return { err: 'internal server error!' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const getReplies = async (utag, cursorInit, limit = 20) => {
|
||||
const did = await getDID(utag);
|
||||
let posts = await agent.getAuthorFeed({ actor: did, limit: limit, includePins: true, cursor: cursorInit });
|
||||
let cursor = posts.data.cursor;
|
||||
const replies = posts.data.feed.filter(o => o.reply);
|
||||
|
||||
while (cursor && replies.length < limit) {
|
||||
posts = await agent.getAuthorFeed({ actor: did, limit: limit, includePins: true, cursor });
|
||||
replies.push(...posts.data.feed.filter(o => o.reply));
|
||||
cursor = posts.data.cursor;
|
||||
}
|
||||
|
||||
return { replies, cursor };
|
||||
}
|
||||
|
||||
|
||||
export const getMedia = async (utag, cursorInit, limit = 20) => {
|
||||
const did = await getDID(utag);
|
||||
let posts = await agent.getAuthorFeed({ actor: did, limit: limit, includePins: true, cursor: cursorInit });
|
||||
let cursor = posts.data.cursor;
|
||||
const replies = posts.data.feed.filter(o => o.reply);
|
||||
|
||||
while (cursor && replies.length < limit) {
|
||||
posts = await agent.getAuthorFeed({ actor: did, limit, includePins: true, cursor });
|
||||
replies.push(...posts.data.feed.filter(o => o.reply));
|
||||
cursor = posts.data.cursor;
|
||||
}
|
||||
|
||||
return { replies, cursor };
|
||||
}
|
||||
|
||||
|
||||
export const getConnections = async (e, utag, cursor, limit = 20) => {
|
||||
const did = await getDID(utag),
|
||||
{ data: { follows: followsRaw, cursor: followsCursor } } = await agent.getFollows({ actor: did, cursor, limit }),
|
||||
{ data: { followers: followersRaw, cursor: followersCursor } } = await agent.getFollowers({ actor: did, cursor, limit }),
|
||||
{ data: { convos } } = await agent.chat.bsky.convo.listConvos({}, { headers: convoHeader }),
|
||||
follows = followsRaw?.length ? (await agent.getProfiles({ actors: followsRaw.map(o => o.did) })).data.profiles : [],
|
||||
followers = followersRaw?.length ? (await agent.getProfiles({ actors: followersRaw.map(o => o.did) })).data.profiles : [];
|
||||
|
||||
return {
|
||||
follows: follows.map(f => {
|
||||
f.dm = convos.find(o => o.members.find(m => m.did === f.did));
|
||||
return f;
|
||||
}), followsCursor, followers, followersCursor
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// export IPC setup function
|
||||
export async function setupIPC() {
|
||||
ipcMain.handle('getdata', async (event, utag, all = false) => {
|
||||
const data = await getUserData(utag, all);
|
||||
event.sender.send('udata', JSON.stringify(data));
|
||||
});
|
||||
|
||||
ipcMain.handle('getposts', async (e, utag, cursor, likescursor) => {
|
||||
if (!cursor) return e.sender.send(404);
|
||||
const data = await getPosts(utag, cursor, likescursor);
|
||||
if (data.code) return e.sender.send(data.code);
|
||||
e.sender.send('posts', JSON.stringify(data));
|
||||
clearCache();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-connections', getConnections);
|
||||
|
||||
ipcMain.handle('gethistory', (e, limit, offset) => e.sender.send('history', JSON.stringify(getHistory(limit, offset))));
|
||||
ipcMain.handle('getreplies', async (e, limit, offset) => e.sender.send('replies', JSON.stringify(await getReplies(limit, offset))));
|
||||
ipcMain.handle('getvideo', async (e, oldurl) => {
|
||||
const newURL = await convertAndServe(`${crypto.randomUUID()}.mp4`, oldurl);
|
||||
e.sender.send('video', oldurl, newURL);
|
||||
});
|
||||
|
||||
ipcMain.handle('send-post', sendPost);
|
||||
ipcMain.handle('new-post', post);
|
||||
ipcMain.handle('upload-file', handleFileOpen);
|
||||
ipcMain.handle('post-action', async (e, action, id, condition) => await handlePostAction(e, action, id, agent, condition));
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { Agent, RichText } from "@atproto/api";
|
||||
import { getPostFiles, isAnimated, popPostFile, uploadFile } from "./files.js";
|
||||
import { initSession } from "./main.js";
|
||||
|
||||
|
||||
async function createEmbed(imgs, vid, embed) {
|
||||
let embdData;
|
||||
|
||||
if (vid) {
|
||||
embdData = {
|
||||
$type: 'app.bsky.embed.video',
|
||||
video: vid[1]
|
||||
}
|
||||
}
|
||||
else if (vid) {
|
||||
embdData = {
|
||||
$type: 'app.bsky.embed.images',
|
||||
images: imgs.map(f => ({ alt: 'image!', image: f[1] }))
|
||||
}
|
||||
}
|
||||
else if (embed) {
|
||||
const img = popPostFile(embed.Image);
|
||||
|
||||
return {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
description: embed.Description,
|
||||
uri: embed.Uri,
|
||||
title: embed.Title,
|
||||
thumb: img
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default async function post(e, postData) {
|
||||
try {
|
||||
const { text, embed } = JSON.parse(postData);
|
||||
|
||||
const agent = await initSession(),
|
||||
files = getPostFiles(),
|
||||
imgs = files.filter(o => !isAnimated(o[1].mimeType)),
|
||||
vid = files.find(o => isAnimated(o[1].mimeType)),
|
||||
rt = new RichText({ text });
|
||||
|
||||
agent.post({
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
langs: ["en-US"],
|
||||
createdAt: new Date().toISOString(),
|
||||
embed: await createEmbed(imgs, vid, embed)
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} e
|
||||
* @param {*} action
|
||||
* @param {*} postid
|
||||
* @param {Agent} agent
|
||||
* @returns
|
||||
*/
|
||||
export async function handlePostAction(e, action, postid, agent, condition = false) {
|
||||
try {
|
||||
if (!postid) return 404;
|
||||
|
||||
let r;
|
||||
const [, did, collection, rkey] = postid.match(/^at:\/\/([^\/]+)\/([^\/]+)\/(.+)$/),
|
||||
post = await agent.getPost({ repo: did, rkey, collection });
|
||||
|
||||
if (action === 'delete') {
|
||||
await agent.deletePost(post.uri);
|
||||
return postid;
|
||||
}
|
||||
else if (action === 'like') return await condition ? agent.deleteLike(condition) : agent.like(post.uri, post.cid);
|
||||
else if (action === 'link') return `https://bsky.app/profile/${did}/post/${rkey}`;
|
||||
else if (action === 'repost' && condition) {
|
||||
await agent.deleteRepost(condition);
|
||||
return postid;
|
||||
}
|
||||
else if (action === 'repost') {
|
||||
const uri = (await agent.repost(post.uri, post.cid)).uri;
|
||||
// const author = await agent.app.bsky.feed.searchPosts({ url: post.uri });
|
||||
// console.log(author)
|
||||
return uri;
|
||||
}
|
||||
else if (action === 'pin') return null; //agent.app.bsky.feed.sendInteractions({interactions: [{event: ''}]})
|
||||
|
||||
|
||||
return JSON.stringify(r);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const baseVideoCachePath = path.resolve('cache', 'videos');
|
||||
if (!fs.existsSync(baseVideoCachePath)) fs.mkdirSync(baseVideoCachePath, { recursive: true });
|
||||
|
||||
// function to convert .m3u8 to .mp4
|
||||
function convertM3U8ToMP4(inpurl, outputPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg(inpurl)
|
||||
.outputOptions('-c copy') // copies the codec without re-encoding for faster processing
|
||||
.output(outputPath)
|
||||
.on('end', resolve)
|
||||
.on('error', reject)
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// clean up the cache
|
||||
export async function clearCache(...vids) {
|
||||
const arr = (vids?.length) ? vids : fs.readdirSync(baseVideoCachePath);
|
||||
await Promise.all(arr.map((p) => new Promise(resolve => fs.rm(path.resolve(baseVideoCachePath, p), resolve))));
|
||||
}
|
||||
|
||||
|
||||
export default async function convertAndServe(fname, m3u8url) {
|
||||
try {
|
||||
const newPath = path.resolve(baseVideoCachePath, fname);
|
||||
await convertM3U8ToMP4(m3u8url, newPath);
|
||||
return fname;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createLogger, format, transports } from 'winston';
|
||||
|
||||
// configure log file paths
|
||||
const logFilePath = './logs/app.log';
|
||||
const errorLogFilePath = './logs/error.log';
|
||||
|
||||
// create a custom format for log messages
|
||||
const logFormat = format.combine(
|
||||
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
format.printf(({ level, message, timestamp }) => `${timestamp} [${level}]: ${message}`)
|
||||
);
|
||||
|
||||
// initialize the logger
|
||||
const logger = createLogger({
|
||||
level: 'info', // default logging level
|
||||
format: logFormat,
|
||||
transports: [
|
||||
new transports.Console(), // log to console
|
||||
new transports.File({ filename: logFilePath }), // log to a general log file
|
||||
new transports.File({ filename: errorLogFilePath, level: 'error' }) // log errors separately
|
||||
],
|
||||
exitOnError: false, // prevent exit on handled exceptions
|
||||
});
|
||||
|
||||
export default logger
|
||||
@@ -0,0 +1,76 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import path from 'path'
|
||||
import { setupIPC } from './bluesky/main.js';
|
||||
import { insertHistory } from './src/db.js';
|
||||
import logger from './logger.js';
|
||||
import { isURL } from './src/renderer.cjs';
|
||||
|
||||
/** @type {BrowserWindow?} */
|
||||
let mainWindow;
|
||||
|
||||
|
||||
const quitApp = async () => {
|
||||
try {
|
||||
app.quit();
|
||||
logger.info("clearing cache...");
|
||||
await (await import('./bluesky/video.js')).clearCache();
|
||||
logger.info('cache cleared!');
|
||||
}
|
||||
catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
finally {
|
||||
process.exit('SIGINT');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// function to create the main application window
|
||||
const createMainWindow = () => {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
preload: path.join(import.meta.dirname, 'src/user_page_preload.cjs'), // enable preload for secure communication
|
||||
contextIsolation: true,
|
||||
enableRemoteModule: false,
|
||||
nodeIntegration: true,
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
mainWindow.webContents.executeJavaScript(`localStorage.getItem('allowlinks') ? true : confirm('allow redicrection to ${details.url}?')`)
|
||||
.then(r => r ? shell.openExternal(details.url) : null);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
mainWindow.loadFile('HTML/index.html'); // load the main HTML file
|
||||
mainWindow.title = 'User Page';
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null // dereference the window object when closed
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('did-navigate', (_, url) => {
|
||||
mainWindow.webContents.executeJavaScript('document.title')
|
||||
.then(title => insertHistory(url, title));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
app.on('ready', () => {
|
||||
setupIPC();
|
||||
process.on('SIGINT', quitApp);
|
||||
createMainWindow();
|
||||
});
|
||||
|
||||
// quit the app on all windows closed, except on macOS
|
||||
app.on('window-all-closed', () => {
|
||||
quitApp();
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
// recreate window if app is re-activated (macOS behavior)
|
||||
app.on('activate', () => {
|
||||
if (mainWindow === null) createMainWindow();
|
||||
});
|
||||
Generated
+3828
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "bluesky-client",
|
||||
"version": "1.0.0",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"rebuild": "electron-rebuild -f -w better-sqlite3"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^3.7.0",
|
||||
"electron": "^33.1.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.13.14",
|
||||
"axios": "^1.7.7",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"cron": "^3.1.9",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"sharp": "^0.33.5",
|
||||
"winston": "^3.16.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
// initialize the database
|
||||
const db = new Database('db/history.db');
|
||||
|
||||
|
||||
// create the table if it doesn't exist
|
||||
db.prepare(`CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT,
|
||||
title TEXT,
|
||||
timestamp TEXT
|
||||
)`).run();
|
||||
|
||||
|
||||
export function insertHistory(url, title) {
|
||||
const latest = getHistory(1),
|
||||
timestamp = new Date().toISOString();
|
||||
|
||||
if (latest?.at(0)?.url === url) return;
|
||||
const stmt = db.prepare(`INSERT INTO history (url, title, timestamp) VALUES (?, ?, ?)`);
|
||||
stmt.run(url, title, timestamp);
|
||||
}
|
||||
|
||||
|
||||
export function deleteHistory(id) {
|
||||
const stmt = db.prepare(`DELETE FROM history WHERE id = ?`);
|
||||
stmt.run(id);
|
||||
}
|
||||
|
||||
|
||||
export function editHistory(id, newUrl, newTitle) {
|
||||
const stmt = db.prepare(`UPDATE history SET url = ?, title = ? WHERE id = ?`);
|
||||
stmt.run(newUrl, newTitle, id);
|
||||
}
|
||||
|
||||
|
||||
export function getHistory(limit = 50, offset = 0) {
|
||||
const stmt = db.prepare(`SELECT * FROM history ORDER BY timestamp DESC LIMIT ? OFFSET ?`);
|
||||
return stmt.all(limit, offset);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
const isURL = (ustr) => {
|
||||
try { return new URL(ustr) }
|
||||
catch (err) { return null }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* formats a string by converting URLs and mentions to HTML links
|
||||
* @param {string} str - the input string to format
|
||||
* @returns {string} - the formatted HTML string
|
||||
*/
|
||||
function formatStr(str) {
|
||||
if (typeof str !== 'string') return ''; // validate input
|
||||
|
||||
const newStr = str.split(/\s+/).map((c) => {
|
||||
// check if string is a URL
|
||||
if (isURL(c)) {
|
||||
return `<a class="inline external-link" href="${c}" target="_blank" rel="noopener noreferrer">${c}</a>`;
|
||||
}
|
||||
// check if string is a mention
|
||||
if (c.startsWith('@')) {
|
||||
const profileLink = c.replace('@', '');
|
||||
return `<a class="inline user-link" href="index.html?profile=${encodeURIComponent(profileLink)}" rel="noopener noreferrer">${c}</a>`;
|
||||
}
|
||||
|
||||
// return the word as-is if it's neither
|
||||
return c;
|
||||
});
|
||||
|
||||
return newStr.join(' ');
|
||||
}
|
||||
|
||||
|
||||
module.exports = { formatStr, isURL };
|
||||
@@ -0,0 +1,165 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
const { populateProfile, Profile } = require('../JS/Profile.cjs');
|
||||
const renderPosts = require('../JS/posts.cjs');
|
||||
const renderReplies = require('../JS/replies.cjs');
|
||||
const renderLikes = require('../JS/likes.cjs');
|
||||
const { displayUploadStatus, renderCompose } = require('../JS/compose.cjs');
|
||||
|
||||
async function handleFileDialogue(e) {
|
||||
try {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
try {
|
||||
// read file data as a buffer
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
// send file data to main process
|
||||
const files = await ipcRenderer.invoke('upload-file', {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
data: Buffer.from(fileBuffer)
|
||||
});
|
||||
|
||||
e.target.dataset.fname = file.name;
|
||||
|
||||
displayUploadStatus(JSON.parse(files));
|
||||
} catch (error) {
|
||||
console.error("Failed to upload file:", error);
|
||||
}
|
||||
}
|
||||
console.log(e.target.files);
|
||||
// console.log('savePath: ', savePath);
|
||||
} catch (e) {
|
||||
console.log('Error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
renderCompose(ipcRenderer);
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const utag = query.get('profile') || '@me';
|
||||
|
||||
ipcRenderer.on('udata', (e, dataRaw) => {
|
||||
setupMutationObserver();
|
||||
|
||||
const data = JSON.parse(dataRaw),
|
||||
pObj = new Profile(data.profile);
|
||||
|
||||
if (data.err) throw data.err;
|
||||
|
||||
console.log(data);
|
||||
if (data.postcursor) sessionStorage.setItem('postcursor', data.postcursor);
|
||||
if (data.likesCursor) sessionStorage.setItem('likescursor', data.likesCursor);
|
||||
|
||||
document.querySelector('#loading')?.remove();
|
||||
populateProfile(pObj);
|
||||
|
||||
if (data.posts) renderPosts(data.posts, data.likes || [], pObj?.pinnedPost, ipcRenderer);
|
||||
if (data.replies) renderReplies(data.replies);
|
||||
if (data.likes) renderPosts(data.likes, data.likes, null, ipcRenderer, 'bskylikeid', 'likes');
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getnewposts: () => {
|
||||
const cursor = sessionStorage.getItem('postcursor'),
|
||||
likescursor = sessionStorage.getItem('likescursor');
|
||||
|
||||
if (cursor) ipcRenderer.invoke('getposts', utag, cursor, likescursor);
|
||||
else if (document.querySelector('#eof')) return;
|
||||
else {
|
||||
const d = document.createElement('div');
|
||||
d.id = 'eof';
|
||||
d.innerHTML = '<h2>Reached end of feed!</h2>';
|
||||
document.querySelector('#posts')?.appendChild(d);
|
||||
}
|
||||
},
|
||||
getnewlikes: () => {
|
||||
return alert("TODO (check TODO.txt)");
|
||||
const cursor = sessionStorage.getItem('postcursor');
|
||||
|
||||
if (cursor) ipcRenderer.invoke('getposts', utag, cursor, likescursor);
|
||||
else if (document.querySelector('#eof')) return;
|
||||
else {
|
||||
const d = document.createElement('div');
|
||||
d.id = 'eof';
|
||||
d.innerHTML = '<h2>Reached end of feed!</h2>';
|
||||
document.querySelector('#posts')?.appendChild(d);
|
||||
}
|
||||
},
|
||||
getHistory: (cursor = undefined) => ipcRenderer.invoke('gethistory', cursor),
|
||||
getReplies: (cursor = undefined) => ipcRenderer.invoke('getreplies', cursor),
|
||||
getVideo: (src) => ipcRenderer.invoke('getvideo', src),
|
||||
});
|
||||
|
||||
ipcRenderer.on('posts', (e, rawData) => {
|
||||
const data = JSON.parse(rawData);
|
||||
console.log(data);
|
||||
|
||||
// reset all videos because the cache was cleared
|
||||
document.querySelectorAll('.post-card video').forEach(video => {
|
||||
video.src = '../assets/video-loading.mp4';
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
});
|
||||
|
||||
if (data.err) return alert(data.err);
|
||||
if (data.feed) renderPosts(data.posts, data.likes?.map(o => ({ posturi: o.post.uri, likeuri: o.post.viewer?.like })), null, ipcRenderer);
|
||||
|
||||
if (data.cursor) sessionStorage.setItem('postcursor', data.cursor);
|
||||
else sessionStorage.removeItem('postcursor');
|
||||
});
|
||||
|
||||
ipcRenderer.on('history', (e, data) => {
|
||||
const hist = JSON.parse(data);
|
||||
console.log(hist);
|
||||
});
|
||||
|
||||
ipcRenderer.on('video', (e, oldurl, newurl) => {
|
||||
const vel = document.querySelector(`video[data-src="${oldurl}"]`);
|
||||
if (!vel) return console.error(`video with url ${oldurl} not found!`);
|
||||
|
||||
vel.parentElement.querySelector('.controls').style.display = '';
|
||||
vel.src = `../cache/videos/${newurl}`;
|
||||
vel.loop = false;
|
||||
vel.removeEventListener('play', vidplaylistenerfunc);
|
||||
vel.play();
|
||||
});
|
||||
|
||||
ipcRenderer.on('replies', (e, data) => renderReplies(JSON.parse(data)));
|
||||
ipcRenderer.invoke('getdata', utag, true);
|
||||
});
|
||||
|
||||
const vidplaylistenerfunc = (e) => {
|
||||
const video = e.target;
|
||||
video.controls = false;
|
||||
video.loop = true;
|
||||
video.parentElement.querySelector('.play-pause').click();
|
||||
ipcRenderer.invoke('getvideo', video.dataset.src);
|
||||
}
|
||||
|
||||
const handleNewVideo = (video) => {
|
||||
const playicon = document.createElement('div');
|
||||
playicon.className = 'play-icon';
|
||||
playicon.innerHTML = '►';
|
||||
video.parentElement.appendChild(playicon);
|
||||
video.addEventListener('click', vidplaylistenerfunc);
|
||||
}
|
||||
|
||||
const setupMutationObserver = () => {
|
||||
new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.tagName === 'VIDEO') handleNewVideo(node);
|
||||
else if (node.tagName === 'INPUT' && node.type == "file") node.addEventListener('change', handleFileDialogue);
|
||||
else {
|
||||
node.querySelectorAll?.('video').forEach(handleNewVideo);
|
||||
document.querySelectorAll('input[type="file"]')?.forEach(el => el.addEventListener('change', handleFileDialogue));
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
Reference in New Issue
Block a user