Initial commit: Beep WordPress plugin
- Custom post type with likes, replies, moderation, shortcodes - Beep icon and logo assets - CSS and JS for frontend
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# Beep
|
||||
|
||||
A Twitter-style microblog for WordPress. Logged-in users post short updates, like, and reply (with reply threads, multiple levels deep). Likes can drive a "Top" feed for ranking the best beeps. Designed to look like a Twitter timeline, hosted entirely on your own site.
|
||||
|
||||
## What's in v1.4
|
||||
|
||||
- **Like beeps and replies.** Hearts on every post and every reply, with a pop animation when you tap.
|
||||
- **Nested replies.** Reply to a reply, to a reply, to a reply. Visual indent caps at two levels (deeper threads still nest structurally).
|
||||
- **Latest / Top tabs.** "Top" sorts your feed by like count — a built-in way for readers to surface the best stuff.
|
||||
- **Per-post Beep badge.** Every beep and reply carries a small bird icon in the corner, branding it as a Beep.
|
||||
- **Friendlier visitor experience.** Logged-out visitors see clean read-only beeps; logged-in users get the full interactive feed.
|
||||
|
||||
## Install Beep
|
||||
|
||||
1. In wp-admin, go to **Plugins → Add New → Upload Plugin**.
|
||||
2. Upload `beep.zip` and click **Install Now**, then **Activate**.
|
||||
3. On the page you want the feed (your one-page site, a dedicated "Feed" page, etc.), add the shortcode:
|
||||
|
||||
```
|
||||
[beep_feed]
|
||||
```
|
||||
|
||||
Save and visit the page. As an admin, you'll see a composer at the top — type a message and click "Beep" to post.
|
||||
|
||||
## Invite friends (with magic links — no passwords)
|
||||
|
||||
This is the easiest way to let a small circle into your Beep feed without setting up Google/Facebook developer apps. The flow:
|
||||
|
||||
1. **Open registration.** Go to **Settings → General**, check **Anyone can register**, and set **New User Default Role** to **Subscriber**. Save.
|
||||
2. **Install passwordless login.** Plugins → Add New → search for **Passwordless Login** (any well-reviewed one — Cozmoslabs or Magic Login both work). Install and activate.
|
||||
3. **Share the sign-up link.** Send your friends a direct link to:
|
||||
|
||||
```
|
||||
https://your-site.com/wp-login.php?action=register
|
||||
```
|
||||
|
||||
They type their email, get a one-time sign-in link, and they're in. No password to remember.
|
||||
4. **They can now like and reply.** Liking and replying require being logged in; your feed remains read-only for the public.
|
||||
5. **Need to remove someone?** Users → click their name → scroll to **Chirp moderation** → check **Suspend from beeping**. They stay registered but can't post or interact.
|
||||
|
||||
## Shortcode options
|
||||
|
||||
```
|
||||
[beep_feed limit="50" header="Messages from" sort="latest"]
|
||||
```
|
||||
|
||||
- `limit` — how many beeps to load on first render (default 50)
|
||||
- `header` — text shown next to the wordmark (default "Messages from")
|
||||
- `sort` — initial sort: `latest` (default) or `top`
|
||||
|
||||
## Moderation
|
||||
|
||||
- **Delete any beep or reply.** Logged-in admins see a small × in the top-right of each item.
|
||||
- **Approve replies before they appear.** Settings → Discussion → "Comment must be manually approved" puts replies in a queue at **Comments** in the sidebar.
|
||||
|
||||
## Branding
|
||||
|
||||
Swap the bird/wordmark in `assets/`. The accent color is `#1D9BF0`; find/replace in `assets/beep.css` for a different palette.
|
||||
|
||||
## What's not in this version
|
||||
|
||||
Deliberate omissions to keep the plugin understandable:
|
||||
|
||||
- Image uploads (text only, 280 char limit)
|
||||
- Retweets / quote-beeps
|
||||
- Direct messages
|
||||
- Custom feeds (no "Following" filter — one global feed)
|
||||
|
||||
## License
|
||||
|
||||
GPLv2 or later, same as WordPress.
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Executable
+1
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 197 KiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Executable
+1
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 217 KiB |
Executable
+760
@@ -0,0 +1,760 @@
|
||||
/* ============================================================
|
||||
Beep — Twitter-style microblog for WordPress
|
||||
All rules are scoped under .beep-feed so they don't leak
|
||||
into the host theme.
|
||||
============================================================ */
|
||||
|
||||
.beep-feed,
|
||||
.beep-feed * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.beep-feed {
|
||||
--beep-bg: #ffffff;
|
||||
--beep-text: #0F1419;
|
||||
--beep-text-secondary: #536471;
|
||||
--beep-border: #EFF3F4;
|
||||
--beep-hover: #F7F9F9;
|
||||
--beep-accent: #1D9BF0;
|
||||
--beep-accent-hover: #1A8CD8;
|
||||
--beep-like: #F91880;
|
||||
--beep-repost: #00BA7C;
|
||||
--beep-page-bg: #E6ECF0;
|
||||
|
||||
max-width: 600px;
|
||||
margin: 24px auto;
|
||||
background: var(--beep-bg);
|
||||
border: 1px solid var(--beep-border);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
color: var(--beep-text);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
.beep-feed-header {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--beep-border);
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.beep-feed-header-text {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--beep-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.beep-feed-header .beep-logo {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ---------- Notices ---------- */
|
||||
.beep-notice {
|
||||
padding: 24px 20px;
|
||||
border-bottom: 1px solid var(--beep-border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.beep-notice p {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.beep-notice-banned {
|
||||
color: var(--beep-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- Avatars ---------- */
|
||||
.beep-feed .beep-avatar {
|
||||
width: 48px !important;
|
||||
height: 48px !important;
|
||||
min-width: 48px !important;
|
||||
max-width: 48px !important;
|
||||
border-radius: 50% !important;
|
||||
flex-shrink: 0;
|
||||
background: #ccd6dd;
|
||||
object-fit: cover !important;
|
||||
aspect-ratio: 1 / 1 !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.beep-feed .beep-avatar-sm {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
min-width: 36px !important;
|
||||
max-width: 36px !important;
|
||||
}
|
||||
|
||||
/* ---------- Compose ---------- */
|
||||
.beep-compose {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--beep-border);
|
||||
}
|
||||
|
||||
.beep-compose-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.beep-feed .beep-input {
|
||||
width: 100% !important;
|
||||
border: 0 !important;
|
||||
outline: 0 !important;
|
||||
resize: none !important;
|
||||
background: #ffffff !important;
|
||||
font-family: inherit !important;
|
||||
font-size: 18px !important;
|
||||
line-height: 1.4 !important;
|
||||
color: var(--beep-text) !important;
|
||||
padding: 8px 0 !important;
|
||||
min-height: 32px;
|
||||
overflow: hidden;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.beep-feed .beep-input::placeholder {
|
||||
color: var(--beep-text-secondary) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.beep-compose-footer,
|
||||
.beep-reply-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--beep-border);
|
||||
padding-top: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Char counter ---------- */
|
||||
.beep-char-counter {
|
||||
font-size: 13px;
|
||||
color: var(--beep-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.beep-counter-warn { color: #FFD400; }
|
||||
.beep-counter-bad { color: #F4212E; font-weight: 700; }
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
.beep-button {
|
||||
display: inline-block;
|
||||
background: var(--beep-accent);
|
||||
color: #fff;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
padding: 8px 18px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
transition: background-color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.beep-button:hover { background: var(--beep-accent-hover); color: #fff; }
|
||||
.beep-button:disabled { opacity: 0.5; cursor: default; }
|
||||
.beep-button:disabled:hover { background: var(--beep-accent); }
|
||||
|
||||
.beep-button-secondary {
|
||||
background: transparent;
|
||||
color: var(--beep-accent);
|
||||
border: 1px solid var(--beep-accent);
|
||||
margin-left: 8px;
|
||||
}
|
||||
.beep-button-secondary:hover {
|
||||
background: rgba(29, 155, 240, 0.1);
|
||||
color: var(--beep-accent);
|
||||
}
|
||||
|
||||
/* ---------- Post ---------- */
|
||||
.beep-list {
|
||||
background: var(--beep-bg);
|
||||
}
|
||||
|
||||
.beep-empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--beep-text-secondary);
|
||||
}
|
||||
|
||||
.beep-post {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--beep-border);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.beep-post:hover { background: var(--beep-hover); }
|
||||
.beep-post:last-child { border-bottom: 0; }
|
||||
|
||||
.beep-post-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---------- Meta line ---------- */
|
||||
.beep-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.beep-name {
|
||||
font-weight: 700;
|
||||
color: var(--beep-text);
|
||||
}
|
||||
|
||||
.beep-handle,
|
||||
.beep-dot,
|
||||
.beep-time {
|
||||
color: var(--beep-text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.beep-time { text-decoration: none; }
|
||||
|
||||
.beep-spacer { flex: 1; }
|
||||
|
||||
.beep-menu-trigger {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--beep-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.beep-menu-trigger:hover {
|
||||
background: rgba(244, 33, 46, 0.1);
|
||||
color: #F4212E;
|
||||
}
|
||||
|
||||
/* ---------- Text ---------- */
|
||||
.beep-text {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
color: var(--beep-text);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 2px 0 12px;
|
||||
}
|
||||
|
||||
.beep-tag,
|
||||
.beep-mention,
|
||||
.beep-link {
|
||||
color: var(--beep-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.beep-tag:hover,
|
||||
.beep-mention:hover,
|
||||
.beep-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.beep-mention-stale {
|
||||
color: var(--beep-accent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ---------- Action row ---------- */
|
||||
.beep-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
max-width: 425px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.beep-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--beep-text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
margin: -6px -8px;
|
||||
border-radius: 9999px;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.beep-action svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.beep-action .beep-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.beep-action-reply:hover { color: var(--beep-accent); background: rgba(29,155,240,0.10); }
|
||||
.beep-action-like:hover { color: var(--beep-like); background: rgba(249, 24,128,0.10); }
|
||||
.beep-action-share:hover { color: var(--beep-accent); background: rgba(29,155,240,0.10); }
|
||||
|
||||
/* Like state */
|
||||
.beep-action-like .beep-icon-filled { display: none; }
|
||||
.beep-action-like .beep-icon-outline { display: inline-flex; }
|
||||
|
||||
.beep-action-like.is-liked {
|
||||
color: var(--beep-like);
|
||||
}
|
||||
.beep-action-like.is-liked .beep-icon-outline { display: none; }
|
||||
.beep-action-like.is-liked .beep-icon-filled { display: inline-flex; }
|
||||
|
||||
/* Heart "pop" animation when newly liked */
|
||||
.beep-action-like.beep-just-liked .beep-icon-filled {
|
||||
animation: beep-heart-pop 0.55s ease;
|
||||
}
|
||||
|
||||
@keyframes beep-heart-pop {
|
||||
0% { transform: scale(0.5); }
|
||||
35% { transform: scale(1.35); }
|
||||
60% { transform: scale(0.85); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ---------- Reply form ---------- */
|
||||
.beep-reply-form {
|
||||
margin-top: 8px;
|
||||
padding: 10px 0 4px;
|
||||
border-top: 1px solid var(--beep-border);
|
||||
}
|
||||
|
||||
.beep-reply-form .beep-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.beep-reply-form[hidden] { display: none; }
|
||||
|
||||
.beep-pending {
|
||||
padding: 12px;
|
||||
background: var(--beep-hover);
|
||||
color: var(--beep-text-secondary);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---------- Replies ---------- */
|
||||
.beep-replies {
|
||||
margin-top: 8px;
|
||||
padding-left: 24px;
|
||||
border-left: 2px solid var(--beep-border);
|
||||
}
|
||||
|
||||
.beep-reply {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--beep-border);
|
||||
}
|
||||
|
||||
.beep-reply:last-child { border-bottom: 0; }
|
||||
|
||||
.beep-reply-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.beep-reply .beep-text {
|
||||
font-size: 14px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ---------- New-post entry animation ---------- */
|
||||
.beep-new {
|
||||
animation: beep-slide-in 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes beep-slide-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ---------- Mobile ---------- */
|
||||
@media (max-width: 640px) {
|
||||
.beep-feed {
|
||||
margin: 0 auto;
|
||||
border-radius: 0;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
.beep-actions { max-width: none; }
|
||||
}
|
||||
|
||||
/* ---------- Tip popover (for "Copied" etc) ---------- */
|
||||
.beep-tip {
|
||||
position: absolute;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
background: var(--beep-text);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
animation: beep-tip-fade 1.5s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes beep-tip-fade {
|
||||
0% { opacity: 0; }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
v1.4 — sort tabs, nested replies, Twitter polish
|
||||
============================================================ */
|
||||
|
||||
/* ---------- Sort tabs (Latest / Top) ---------- */
|
||||
.beep-feed .beep-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--beep-border);
|
||||
background: var(--beep-bg);
|
||||
}
|
||||
|
||||
.beep-feed .beep-tab {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 14px 0;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--beep-text-secondary);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.beep-feed .beep-tab:hover {
|
||||
background: var(--beep-hover);
|
||||
color: var(--beep-text);
|
||||
}
|
||||
|
||||
.beep-feed .beep-tab.is-active {
|
||||
color: var(--beep-text);
|
||||
}
|
||||
|
||||
.beep-feed .beep-tab.is-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
width: 56px;
|
||||
height: 4px;
|
||||
border-radius: 9999px;
|
||||
background: var(--beep-accent);
|
||||
}
|
||||
|
||||
/* ---------- Loading state on feed list ---------- */
|
||||
.beep-feed .beep-list.beep-loading {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
/* ---------- Action row variant for replies (smaller, no share) ---------- */
|
||||
.beep-feed .beep-actions-reply {
|
||||
max-width: 240px;
|
||||
gap: 24px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---------- Nested replies: visual indent, capped at depth 2 ---------- */
|
||||
.beep-feed .beep-replies-nested {
|
||||
margin-top: 8px;
|
||||
margin-left: 12px;
|
||||
padding-left: 14px;
|
||||
border-left: 2px solid var(--beep-border);
|
||||
}
|
||||
|
||||
/* Anything deeper than depth 2 stops indenting more */
|
||||
.beep-feed .beep-depth-2 .beep-replies-nested,
|
||||
.beep-feed .beep-depth-3 .beep-replies-nested,
|
||||
.beep-feed .beep-depth-4 .beep-replies-nested {
|
||||
margin-left: 0;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* ---------- Focus rings on text inputs ---------- */
|
||||
.beep-feed .beep-input:focus,
|
||||
.beep-feed .beep-input:focus-visible {
|
||||
outline: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.beep-feed .beep-compose:focus-within,
|
||||
.beep-feed .beep-reply-form:focus-within {
|
||||
background: linear-gradient(to bottom, transparent, rgba(29, 155, 240, 0.04));
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* ---------- Buttons: refine to match Twitter's "Post" button ---------- */
|
||||
.beep-feed .beep-post-button,
|
||||
.beep-feed .beep-reply-submit {
|
||||
font-size: 14px;
|
||||
padding: 7px 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.1px;
|
||||
}
|
||||
|
||||
/* ---------- Tighter post hover ---------- */
|
||||
.beep-feed .beep-post {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* ---------- Refine action icons size ---------- */
|
||||
.beep-feed .beep-action {
|
||||
padding: 4px 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.beep-feed .beep-action svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
/* ---------- Reply rows: tighten spacing ---------- */
|
||||
.beep-feed .beep-reply {
|
||||
padding: 10px 0 8px;
|
||||
}
|
||||
|
||||
/* Make sure reply meta + actions feel cohesive */
|
||||
.beep-feed .beep-reply .beep-text {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Per-post Beep badge (top-right of each post/reply) ---------- */
|
||||
.beep-feed .beep-post-badge {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
opacity: 0.75;
|
||||
margin-left: 4px;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.beep-feed .beep-post-badge:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.beep-feed .beep-post-badge-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ---------- Delete row (bottom of post/reply, admin only) ---------- */
|
||||
.beep-feed .beep-delete-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.beep-feed .beep-delete-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--beep-text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: color 0.15s ease, background-color 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.beep-feed .beep-delete-link:hover {
|
||||
color: #F4212E;
|
||||
background: rgba(244, 33, 46, 0.10);
|
||||
}
|
||||
|
||||
.beep-feed .beep-delete-link:active {
|
||||
color: #F4212E;
|
||||
background: rgba(244, 33, 46, 0.25);
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* Override earlier 20/16 badge sizes with bigger 32/24 */
|
||||
.beep-feed .beep-post-badge {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.beep-feed .beep-post-badge-sm {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
v1.4.1 — theme-busting overrides
|
||||
Many WordPress themes style ALL <button> elements as big
|
||||
blue gradient blocks. Force our icon-only buttons back to
|
||||
clean transparent icons that only show color on hover.
|
||||
============================================================ */
|
||||
.beep-feed button.beep-action,
|
||||
.beep-feed button.beep-delete-link,
|
||||
.beep-feed button.beep-menu-trigger,
|
||||
.beep-feed button.beep-tab,
|
||||
.beep-feed button[data-beep-toggle-reply],
|
||||
.beep-feed button[data-beep-like-post],
|
||||
.beep-feed button[data-beep-like-reply],
|
||||
.beep-feed button[data-beep-share],
|
||||
.beep-feed button[data-beep-delete],
|
||||
.beep-feed button[data-beep-delete-reply] {
|
||||
background: transparent !important;
|
||||
background-image: none !important;
|
||||
background-color: transparent !important;
|
||||
border: 0 !important;
|
||||
border-radius: 9999px !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
text-transform: none !important;
|
||||
letter-spacing: 0 !important;
|
||||
min-height: 0 !important;
|
||||
min-width: 0 !important;
|
||||
height: auto !important;
|
||||
width: auto !important;
|
||||
line-height: 1 !important;
|
||||
font-family: inherit !important;
|
||||
color: var(--beep-text-secondary) !important;
|
||||
}
|
||||
|
||||
/* Specific sizing for action buttons */
|
||||
.beep-feed button.beep-action {
|
||||
padding: 6px 8px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 400 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 6px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Hover/active recoloring */
|
||||
.beep-feed button.beep-action-reply:hover {
|
||||
color: var(--beep-accent) !important;
|
||||
background: rgba(29, 155, 240, 0.10) !important;
|
||||
}
|
||||
.beep-feed button.beep-action-like:hover,
|
||||
.beep-feed button.beep-action-like.is-liked {
|
||||
color: var(--beep-like) !important;
|
||||
}
|
||||
.beep-feed button.beep-action-like:hover {
|
||||
background: rgba(249, 24, 128, 0.10) !important;
|
||||
}
|
||||
.beep-feed button.beep-action-share:hover {
|
||||
color: var(--beep-accent) !important;
|
||||
background: rgba(29, 155, 240, 0.10) !important;
|
||||
}
|
||||
|
||||
/* Delete × button: small round icon */
|
||||
.beep-feed button.beep-delete-link {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 22px !important;
|
||||
font-weight: 400 !important;
|
||||
border-radius: 50% !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.beep-feed button.beep-delete-link:hover {
|
||||
color: #F4212E !important;
|
||||
background: rgba(244, 33, 46, 0.10) !important;
|
||||
}
|
||||
.beep-feed button.beep-delete-link:active {
|
||||
color: #F4212E !important;
|
||||
background: rgba(244, 33, 46, 0.25) !important;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* Tab buttons */
|
||||
.beep-feed button.beep-tab {
|
||||
flex: 1 !important;
|
||||
padding: 14px 0 !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 700 !important;
|
||||
border-radius: 0 !important;
|
||||
color: var(--beep-text-secondary) !important;
|
||||
}
|
||||
.beep-feed button.beep-tab.is-active {
|
||||
color: var(--beep-text) !important;
|
||||
}
|
||||
.beep-feed button.beep-tab:hover {
|
||||
background: var(--beep-hover) !important;
|
||||
}
|
||||
|
||||
/* The Beep/Reply submit buttons keep their solid blue pill */
|
||||
.beep-feed button.beep-button {
|
||||
background: var(--beep-accent) !important;
|
||||
background-image: none !important;
|
||||
background-color: var(--beep-accent) !important;
|
||||
color: #fff !important;
|
||||
border: 0 !important;
|
||||
border-radius: 9999px !important;
|
||||
padding: 7px 18px !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 14px !important;
|
||||
letter-spacing: -0.1px !important;
|
||||
width: auto !important;
|
||||
min-height: 0 !important;
|
||||
height: auto !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.beep-feed button.beep-button:hover {
|
||||
background: var(--beep-accent-hover) !important;
|
||||
}
|
||||
.beep-feed button.beep-button:disabled {
|
||||
opacity: 0.5 !important;
|
||||
cursor: default !important;
|
||||
}
|
||||
Executable
+332
@@ -0,0 +1,332 @@
|
||||
/* Beep — Twitter-style microblog frontend */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var config = window.BeepConfig || {};
|
||||
var limit = (config.charLimit || 280) | 0;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
function init() {
|
||||
var feeds = document.querySelectorAll('[data-beep-feed]');
|
||||
Array.prototype.forEach.call(feeds, initFeed);
|
||||
}
|
||||
|
||||
function initFeed(feed) {
|
||||
var compose = feed.querySelector('[data-beep-compose]');
|
||||
if (compose) initCompose(compose, feed);
|
||||
initSortTabs(feed);
|
||||
feed.addEventListener('click', onClick);
|
||||
feed.addEventListener('input', onInput);
|
||||
feed.addEventListener('keydown', onKeydown);
|
||||
}
|
||||
|
||||
/* ---------- Composer ---------- */
|
||||
function initCompose(compose, feed) {
|
||||
var input = compose.querySelector('[data-beep-input]');
|
||||
var counter = compose.querySelector('[data-beep-counter]');
|
||||
var button = compose.querySelector('[data-beep-submit]');
|
||||
if (!input || !button) return;
|
||||
|
||||
function refresh() {
|
||||
autosize(input);
|
||||
var remaining = limit - countChars(input.value);
|
||||
counter.textContent = remaining;
|
||||
counter.classList.toggle('beep-counter-warn', remaining < 20 && remaining >= 0);
|
||||
counter.classList.toggle('beep-counter-bad', remaining < 0);
|
||||
button.disabled = input.value.trim() === '' || remaining < 0;
|
||||
}
|
||||
input.addEventListener('input', refresh);
|
||||
refresh();
|
||||
|
||||
button.addEventListener('click', function () {
|
||||
var content = input.value.trim();
|
||||
if (!content) return;
|
||||
button.disabled = true;
|
||||
fetch(config.restUrl + 'post', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ content: content })
|
||||
})
|
||||
.then(handleResponse)
|
||||
.then(function (data) {
|
||||
var list = feed.querySelector('[data-beep-list]');
|
||||
var empty = list.querySelector('.beep-empty');
|
||||
if (empty) empty.remove();
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = data.html;
|
||||
var node = tmp.firstElementChild;
|
||||
if (node) {
|
||||
node.classList.add('beep-new');
|
||||
list.insertBefore(node, list.firstChild);
|
||||
}
|
||||
input.value = '';
|
||||
refresh();
|
||||
})
|
||||
.catch(function () {
|
||||
window.alert('Could not post. Please try again.');
|
||||
button.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Sort tabs ---------- */
|
||||
function initSortTabs(feed) {
|
||||
var tabs = feed.querySelector('[data-beep-tabs]');
|
||||
if (!tabs) return;
|
||||
tabs.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-beep-sort]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
var sort = btn.dataset.beepSort;
|
||||
tabs.querySelectorAll('[data-beep-sort]').forEach(function (b) {
|
||||
b.classList.toggle('is-active', b === btn);
|
||||
});
|
||||
reloadFeed(feed, sort);
|
||||
});
|
||||
}
|
||||
|
||||
function reloadFeed(feed, sort) {
|
||||
var list = feed.querySelector('[data-beep-list]');
|
||||
if (!list) return;
|
||||
list.classList.add('beep-loading');
|
||||
fetch(config.restUrl + 'feed?sort=' + encodeURIComponent(sort), {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-WP-Nonce': config.nonce }
|
||||
})
|
||||
.then(handleResponse)
|
||||
.then(function (data) {
|
||||
list.innerHTML = data.html || '<div class="beep-empty">No beeps yet.</div>';
|
||||
list.classList.remove('beep-loading');
|
||||
})
|
||||
.catch(function () {
|
||||
list.classList.remove('beep-loading');
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Click delegation ---------- */
|
||||
function onClick(e) {
|
||||
var t = e.target;
|
||||
|
||||
var likePost = t.closest('[data-beep-like-post]');
|
||||
if (likePost) { e.preventDefault(); if (requireLogin()) handleLike(likePost, 'like/' + likePost.dataset.beepLikePost); return; }
|
||||
|
||||
var likeReply = t.closest('[data-beep-like-reply]');
|
||||
if (likeReply) { e.preventDefault(); if (requireLogin()) handleLike(likeReply, 'reply-like/' + likeReply.dataset.beepLikeReply); return; }
|
||||
|
||||
var replyToggle = t.closest('[data-beep-toggle-reply]');
|
||||
if (replyToggle) {
|
||||
e.preventDefault();
|
||||
if (!requireLogin()) return;
|
||||
// The reply form is the sibling .beep-reply-form belonging to the post or reply
|
||||
// that this toggle button is inside.
|
||||
var container = replyToggle.closest('[data-beep-post], [data-beep-reply]');
|
||||
if (!container) return;
|
||||
var form = container.querySelector(':scope > .beep-post-body > [data-beep-reply-form], :scope > .beep-reply-body > [data-beep-reply-form]');
|
||||
if (!form) return;
|
||||
form.hidden = !form.hidden;
|
||||
if (!form.hidden) {
|
||||
var ta = form.querySelector('textarea');
|
||||
if (ta) ta.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var replyBtn = t.closest('[data-beep-reply-submit]');
|
||||
if (replyBtn) { e.preventDefault(); handleReplySubmit(replyBtn); return; }
|
||||
|
||||
var shareBtn = t.closest('[data-beep-share]');
|
||||
if (shareBtn) { e.preventDefault(); handleShare(shareBtn); return; }
|
||||
|
||||
var delPost = t.closest('[data-beep-delete]');
|
||||
if (delPost) {
|
||||
e.preventDefault();
|
||||
if (!window.confirm('Delete this beep?')) return;
|
||||
handleDelete('beep/' + delPost.dataset.beepDelete, '[data-beep-post="' + delPost.dataset.beepDelete + '"]');
|
||||
return;
|
||||
}
|
||||
|
||||
var delReply = t.closest('[data-beep-delete-reply]');
|
||||
if (delReply) {
|
||||
e.preventDefault();
|
||||
if (!window.confirm('Delete this reply?')) return;
|
||||
handleDelete('reply/' + delReply.dataset.beepDeleteReply, '[data-beep-reply="' + delReply.dataset.beepDeleteReply + '"]');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
var input = e.target.closest('.beep-reply-input, .beep-compose-input');
|
||||
if (!input) return;
|
||||
autosize(input);
|
||||
var counter = input.parentElement.querySelector('.beep-char-counter');
|
||||
var submit = input.parentElement.querySelector('.beep-button');
|
||||
if (counter) {
|
||||
var remaining = limit - countChars(input.value);
|
||||
counter.textContent = remaining;
|
||||
counter.classList.toggle('beep-counter-warn', remaining < 20 && remaining >= 0);
|
||||
counter.classList.toggle('beep-counter-bad', remaining < 0);
|
||||
if (submit) submit.disabled = input.value.trim() === '' || remaining < 0;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (!((e.metaKey || e.ctrlKey) && e.key === 'Enter')) return;
|
||||
var input = e.target.closest('textarea');
|
||||
if (!input) return;
|
||||
var wrap = input.closest('.beep-compose, .beep-reply-form');
|
||||
if (!wrap) return;
|
||||
var btn = wrap.querySelector('button');
|
||||
if (btn && !btn.disabled) btn.click();
|
||||
}
|
||||
|
||||
/* ---------- Like (works for both posts and replies) ---------- */
|
||||
function handleLike(btn, urlFragment) {
|
||||
var countEl = btn.querySelector('[data-beep-like-count]');
|
||||
var was = btn.classList.contains('is-liked');
|
||||
|
||||
btn.classList.toggle('is-liked');
|
||||
if (!was) {
|
||||
btn.classList.add('beep-just-liked');
|
||||
setTimeout(function () { btn.classList.remove('beep-just-liked'); }, 600);
|
||||
}
|
||||
if (countEl) {
|
||||
var current = parseInt(countEl.textContent, 10) || 0;
|
||||
var optimistic = current + (was ? -1 : 1);
|
||||
countEl.textContent = optimistic > 0 ? optimistic : '';
|
||||
}
|
||||
|
||||
fetch(config.restUrl + urlFragment, {
|
||||
method: 'POST',
|
||||
headers: { 'X-WP-Nonce': config.nonce },
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(handleResponse)
|
||||
.then(function (data) {
|
||||
if (countEl) countEl.textContent = data.count > 0 ? data.count : '';
|
||||
btn.classList.toggle('is-liked', !!data.liked);
|
||||
})
|
||||
.catch(function () {
|
||||
btn.classList.toggle('is-liked', was);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Reply submit (top-level or nested) ---------- */
|
||||
function handleReplySubmit(btn) {
|
||||
var postId = btn.dataset.beepReplySubmit;
|
||||
var parent = btn.dataset.beepReplyParent || '0';
|
||||
var form = btn.closest('[data-beep-reply-form]');
|
||||
var input = form && form.querySelector('textarea');
|
||||
if (!input) return;
|
||||
var content = input.value.trim();
|
||||
if (!content) return;
|
||||
btn.disabled = true;
|
||||
|
||||
fetch(config.restUrl + 'reply/' + postId, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ content: content, parent: parseInt(parent, 10) || 0 })
|
||||
})
|
||||
.then(handleResponse)
|
||||
.then(function (data) {
|
||||
if (data.pending) {
|
||||
form.innerHTML = '<div class="beep-pending">Reply submitted, awaiting moderation.</div>';
|
||||
return;
|
||||
}
|
||||
// Where do we insert?
|
||||
// If parent === 0, append to the post's top-level [data-beep-replies] list.
|
||||
// Otherwise append (creating if needed) to the parent reply's nested list.
|
||||
var post = btn.closest('[data-beep-post]');
|
||||
var targetList;
|
||||
if ((parseInt(parent, 10) || 0) === 0) {
|
||||
targetList = post && post.querySelector(':scope > .beep-post-body > [data-beep-replies]');
|
||||
} else {
|
||||
var parentReply = post && post.querySelector('[data-beep-reply="' + parent + '"]');
|
||||
if (parentReply) {
|
||||
targetList = parentReply.querySelector(':scope > .beep-reply-body > .beep-replies-nested');
|
||||
if (!targetList) {
|
||||
targetList = document.createElement('div');
|
||||
targetList.className = 'beep-replies beep-replies-nested';
|
||||
targetList.setAttribute('data-beep-replies', '');
|
||||
parentReply.querySelector('.beep-reply-body').appendChild(targetList);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetList) {
|
||||
var tmp = document.createElement('div');
|
||||
tmp.innerHTML = data.html;
|
||||
var node = tmp.firstElementChild;
|
||||
if (node) {
|
||||
node.classList.add('beep-new');
|
||||
targetList.appendChild(node);
|
||||
}
|
||||
}
|
||||
// Increment top-level reply counter on the post.
|
||||
var countEl = post && post.querySelector('[data-beep-reply-count]');
|
||||
if (countEl) {
|
||||
countEl.textContent = (parseInt(countEl.textContent, 10) || 0) + 1;
|
||||
}
|
||||
input.value = '';
|
||||
autosize(input);
|
||||
form.hidden = true;
|
||||
})
|
||||
.catch(function () {
|
||||
window.alert('Could not post reply. Please try again.');
|
||||
})
|
||||
.finally(function () { btn.disabled = false; });
|
||||
}
|
||||
|
||||
/* ---------- Share (copy link) ---------- */
|
||||
function handleShare(btn) {
|
||||
var url = btn.dataset.beepShare;
|
||||
if (!url || !navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(url).then(function () {
|
||||
var tip = document.createElement('span');
|
||||
tip.className = 'beep-tip';
|
||||
tip.textContent = 'Copied';
|
||||
btn.style.position = 'relative';
|
||||
btn.appendChild(tip);
|
||||
setTimeout(function () { tip.remove(); }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Delete ---------- */
|
||||
function handleDelete(pathFragment, selector) {
|
||||
fetch(config.restUrl + pathFragment, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-WP-Nonce': config.nonce },
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(handleResponse)
|
||||
.then(function () {
|
||||
var el = document.querySelector(selector);
|
||||
if (el) el.remove();
|
||||
})
|
||||
.catch(function () {
|
||||
window.alert('Could not delete.');
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
function requireLogin() {
|
||||
if (config.isLoggedIn) return true;
|
||||
if (config.loginUrl) window.location.href = config.loginUrl;
|
||||
return false;
|
||||
}
|
||||
|
||||
function autosize(el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
function countChars(s) {
|
||||
return Array.from(s).length;
|
||||
}
|
||||
|
||||
function handleResponse(res) {
|
||||
if (!res.ok) return res.json().then(function (j) { throw j; }, function () { throw res; });
|
||||
return res.json();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: Beep
|
||||
* Description: A Twitter-style microblog for WordPress, with likes, replies, and Gravatars. Use the [beep_feed] shortcode on any page.
|
||||
* Version: 1.4.1
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: Sami Ahmed
|
||||
* License: GPL-2.0-or-later
|
||||
* Text Domain: beep
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('BEEP_VERSION', '1.4.1');
|
||||
define('BEEP_FILE', __FILE__);
|
||||
define('BEEP_DIR', plugin_dir_path(__FILE__));
|
||||
define('BEEP_URL', plugin_dir_url(__FILE__));
|
||||
define('BEEP_LIKES_TABLE', 'beep_likes');
|
||||
define('BEEP_CHAR_LIMIT', 280);
|
||||
|
||||
require_once BEEP_DIR . 'includes/helpers.php';
|
||||
require_once BEEP_DIR . 'includes/class-cpt.php';
|
||||
require_once BEEP_DIR . 'includes/class-likes.php';
|
||||
require_once BEEP_DIR . 'includes/class-replies.php';
|
||||
require_once BEEP_DIR . 'includes/class-moderation.php';
|
||||
require_once BEEP_DIR . 'includes/class-shortcode.php';
|
||||
require_once BEEP_DIR . 'includes/class-beep.php';
|
||||
|
||||
register_activation_hook(__FILE__, ['Beep_Plugin', 'activate']);
|
||||
register_deactivation_hook(__FILE__, ['Beep_Plugin', 'deactivate']);
|
||||
|
||||
add_action('plugins_loaded', ['Beep_Plugin', 'init']);
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Beep_Plugin {
|
||||
|
||||
public static function init() {
|
||||
Beep_CPT::init();
|
||||
Beep_Likes::init();
|
||||
Beep_Replies::init();
|
||||
Beep_Moderation::init();
|
||||
Beep_Shortcode::init();
|
||||
|
||||
add_action('wp_enqueue_scripts', [__CLASS__, 'enqueue_assets']);
|
||||
}
|
||||
|
||||
public static function activate() {
|
||||
Beep_CPT::register();
|
||||
Beep_Likes::create_table();
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
public static function deactivate() {
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
public static function enqueue_assets() {
|
||||
wp_enqueue_style(
|
||||
'beep',
|
||||
BEEP_URL . 'assets/beep.css',
|
||||
[],
|
||||
BEEP_VERSION
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'beep',
|
||||
BEEP_URL . 'assets/beep.js',
|
||||
[],
|
||||
BEEP_VERSION,
|
||||
true
|
||||
);
|
||||
wp_localize_script('beep', 'BeepConfig', [
|
||||
'restUrl' => esc_url_raw(rest_url('beep/v1/')),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'charLimit' => BEEP_CHAR_LIMIT,
|
||||
'loginUrl' => esc_url_raw(wp_login_url(is_singular() ? get_permalink() : home_url('/'))),
|
||||
'isLoggedIn' => is_user_logged_in(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Executable
+48
File diff suppressed because one or more lines are too long
Executable
+144
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Beep_Likes {
|
||||
|
||||
public static function init() {
|
||||
add_action('rest_api_init', [__CLASS__, 'register_routes']);
|
||||
}
|
||||
|
||||
public static function table_name() {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . BEEP_LIKES_TABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or migrate the likes table.
|
||||
* The 'post_id' column historically only stored beep IDs; we now also
|
||||
* use it for comment IDs (replies). 'object_type' distinguishes them.
|
||||
*/
|
||||
public static function create_table() {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE $table (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
object_type VARCHAR(20) NOT NULL DEFAULT 'post',
|
||||
post_id BIGINT(20) UNSIGNED NOT NULL,
|
||||
user_id BIGINT(20) UNSIGNED NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY object_user (object_type, post_id, user_id),
|
||||
KEY post_id (post_id),
|
||||
KEY user_id (user_id)
|
||||
) $charset;";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta($sql);
|
||||
|
||||
// Migration for installs from v1.0-1.3: add object_type column if missing.
|
||||
$col = $wpdb->get_var($wpdb->prepare(
|
||||
"SHOW COLUMNS FROM `$table` LIKE %s", 'object_type'
|
||||
));
|
||||
if (!$col) {
|
||||
$wpdb->query("ALTER TABLE `$table` ADD COLUMN object_type VARCHAR(20) NOT NULL DEFAULT 'post' AFTER id");
|
||||
}
|
||||
}
|
||||
|
||||
public static function register_routes() {
|
||||
// Like a beep (post).
|
||||
register_rest_route('beep/v1', '/like/(?P<id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'toggle_post_like'],
|
||||
'permission_callback' => [__CLASS__, 'can_interact'],
|
||||
]);
|
||||
|
||||
// Like a reply (comment).
|
||||
register_rest_route('beep/v1', '/reply-like/(?P<id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'toggle_reply_like'],
|
||||
'permission_callback' => [__CLASS__, 'can_interact'],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function can_interact() {
|
||||
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
}
|
||||
|
||||
public static function toggle_post_like($request) {
|
||||
$id = (int) $request['id'];
|
||||
if (get_post_type($id) !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('beep_not_a_beep', 'Not a beep.', ['status' => 400]);
|
||||
}
|
||||
return rest_ensure_response(self::toggle($id, 'post'));
|
||||
}
|
||||
|
||||
public static function toggle_reply_like($request) {
|
||||
$id = (int) $request['id'];
|
||||
$c = get_comment($id);
|
||||
if (!$c || get_post_type($c->comment_post_ID) !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('beep_not_a_reply', 'Not a reply.', ['status' => 400]);
|
||||
}
|
||||
return rest_ensure_response(self::toggle($id, 'reply'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a like for current user on the given object.
|
||||
* @return array {liked: bool, count: int}
|
||||
*/
|
||||
public static function toggle($object_id, $object_type) {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
$existing = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM $table WHERE object_type = %s AND post_id = %d AND user_id = %d",
|
||||
$object_type, $object_id, $user_id
|
||||
));
|
||||
|
||||
if ($existing) {
|
||||
$wpdb->delete($table, ['id' => $existing], ['%d']);
|
||||
$liked = false;
|
||||
} else {
|
||||
$wpdb->insert($table, [
|
||||
'object_type' => $object_type,
|
||||
'post_id' => $object_id,
|
||||
'user_id' => $user_id,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%d', '%s']);
|
||||
$liked = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'liked' => $liked,
|
||||
'count' => self::get_count($object_id, $object_type),
|
||||
];
|
||||
}
|
||||
|
||||
public static function get_count($object_id, $object_type = 'post') {
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM $table WHERE object_type = %s AND post_id = %d",
|
||||
$object_type, $object_id
|
||||
));
|
||||
}
|
||||
|
||||
public static function user_has_liked($object_id, $object_type = 'post', $user_id = null) {
|
||||
global $wpdb;
|
||||
if (!$user_id) {
|
||||
$user_id = get_current_user_id();
|
||||
}
|
||||
if (!$user_id) {
|
||||
return false;
|
||||
}
|
||||
$table = self::table_name();
|
||||
return (bool) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM $table WHERE object_type = %s AND post_id = %d AND user_id = %d",
|
||||
$object_type, $object_id, $user_id
|
||||
));
|
||||
}
|
||||
}
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Beep_Moderation {
|
||||
|
||||
const BAN_META_KEY = 'beep_banned';
|
||||
|
||||
public static function init() {
|
||||
add_action('show_user_profile', [__CLASS__, 'profile_field']);
|
||||
add_action('edit_user_profile', [__CLASS__, 'profile_field']);
|
||||
add_action('personal_options_update', [__CLASS__, 'save_profile_field']);
|
||||
add_action('edit_user_profile_update', [__CLASS__, 'save_profile_field']);
|
||||
}
|
||||
|
||||
public static function profile_field($user) {
|
||||
if (!current_user_can('edit_users') && get_current_user_id() !== $user->ID) {
|
||||
return;
|
||||
}
|
||||
$can_ban = current_user_can('edit_users');
|
||||
$banned = get_user_meta($user->ID, self::BAN_META_KEY, true) === '1';
|
||||
?>
|
||||
<h2>Beep moderation</h2>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th><label for="beep_banned">Beep status</label></th>
|
||||
<td>
|
||||
<?php if ($can_ban) : ?>
|
||||
<label>
|
||||
<input type="checkbox" name="beep_banned" id="beep_banned" value="1" <?php checked($banned); ?>>
|
||||
Suspend this user from beeping, liking, and replying
|
||||
</label>
|
||||
<?php else : ?>
|
||||
<?php echo $banned ? 'Your account is currently suspended.' : 'Active.'; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function save_profile_field($user_id) {
|
||||
if (!current_user_can('edit_users')) {
|
||||
return;
|
||||
}
|
||||
$val = isset($_POST['beep_banned']) ? '1' : '';
|
||||
update_user_meta($user_id, self::BAN_META_KEY, $val);
|
||||
}
|
||||
}
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Beep_Replies {
|
||||
|
||||
public static function init() {
|
||||
add_action('rest_api_init', [__CLASS__, 'register_routes']);
|
||||
}
|
||||
|
||||
public static function register_routes() {
|
||||
register_rest_route('beep/v1', '/post', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'submit_beep'],
|
||||
'permission_callback' => [Beep_Likes::class, 'can_interact'],
|
||||
]);
|
||||
|
||||
register_rest_route('beep/v1', '/reply/(?P<id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'submit_reply'],
|
||||
'permission_callback' => [Beep_Likes::class, 'can_interact'],
|
||||
]);
|
||||
|
||||
register_rest_route('beep/v1', '/beep/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_beep'],
|
||||
'permission_callback' => function ($req) {
|
||||
return current_user_can('delete_post', (int) $req['id']);
|
||||
},
|
||||
]);
|
||||
|
||||
register_rest_route('beep/v1', '/reply/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_reply'],
|
||||
'permission_callback' => function () {
|
||||
return current_user_can('moderate_comments');
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
public static function submit_beep($request) {
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
$length = function_exists('mb_strlen') ? mb_strlen($content) : strlen($content);
|
||||
|
||||
if ($content === '' || $length > BEEP_CHAR_LIMIT) {
|
||||
return new WP_Error('beep_invalid_content',
|
||||
'Content must be 1-' . BEEP_CHAR_LIMIT . ' characters.',
|
||||
['status' => 400]);
|
||||
}
|
||||
|
||||
$post_id = wp_insert_post([
|
||||
'post_type' => Beep_CPT::POST_TYPE,
|
||||
'post_status' => 'publish',
|
||||
'post_author' => get_current_user_id(),
|
||||
'post_content' => wp_kses($content, []),
|
||||
'post_title' => wp_trim_words($content, 8, '...'),
|
||||
], true);
|
||||
|
||||
if (is_wp_error($post_id)) {
|
||||
return $post_id;
|
||||
}
|
||||
|
||||
return rest_ensure_response([
|
||||
'id' => $post_id,
|
||||
'html' => beep_render_post(get_post($post_id)),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function submit_reply($request) {
|
||||
$post_id = (int) $request['id'];
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
$parent_id = (int) $request->get_param('parent');
|
||||
$length = function_exists('mb_strlen') ? mb_strlen($content) : strlen($content);
|
||||
|
||||
if (get_post_type($post_id) !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('beep_not_a_beep', 'Not a beep.', ['status' => 400]);
|
||||
}
|
||||
if ($content === '' || $length > BEEP_CHAR_LIMIT) {
|
||||
return new WP_Error('beep_invalid_content',
|
||||
'Reply must be 1-' . BEEP_CHAR_LIMIT . ' characters.',
|
||||
['status' => 400]);
|
||||
}
|
||||
// Validate the parent comment belongs to this post if provided.
|
||||
if ($parent_id > 0) {
|
||||
$parent = get_comment($parent_id);
|
||||
if (!$parent || (int) $parent->comment_post_ID !== $post_id) {
|
||||
return new WP_Error('beep_bad_parent', 'Invalid parent.', ['status' => 400]);
|
||||
}
|
||||
}
|
||||
|
||||
$user = wp_get_current_user();
|
||||
|
||||
$approved = get_option('comment_moderation') ? 0 : 1;
|
||||
if (get_option('comment_previously_approved')) {
|
||||
$prev = get_comments([
|
||||
'user_id' => $user->ID,
|
||||
'status' => 'approve',
|
||||
'count' => true,
|
||||
]);
|
||||
if (!$prev) {
|
||||
$approved = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$comment_id = wp_insert_comment([
|
||||
'comment_post_ID' => $post_id,
|
||||
'comment_author' => $user->display_name,
|
||||
'comment_author_email' => $user->user_email,
|
||||
'comment_author_url' => '',
|
||||
'comment_content' => wp_kses($content, []),
|
||||
'comment_type' => 'comment',
|
||||
'comment_parent' => $parent_id,
|
||||
'user_id' => $user->ID,
|
||||
'comment_approved' => $approved,
|
||||
]);
|
||||
|
||||
if (!$comment_id) {
|
||||
return new WP_Error('beep_insert_failed', 'Reply could not be saved.', ['status' => 500]);
|
||||
}
|
||||
|
||||
$comment = get_comment($comment_id);
|
||||
$pending = ($comment->comment_approved == 0);
|
||||
|
||||
// Depth of this reply for client-side indenting.
|
||||
$depth = 0;
|
||||
$cur = $comment;
|
||||
while ($cur && (int) $cur->comment_parent > 0 && $depth < 10) {
|
||||
$cur = get_comment((int) $cur->comment_parent);
|
||||
$depth++;
|
||||
}
|
||||
$can_interact = Beep_Likes::can_interact();
|
||||
|
||||
return rest_ensure_response([
|
||||
'id' => $comment_id,
|
||||
'parent' => (int) $comment->comment_parent,
|
||||
'pending' => $pending,
|
||||
'html' => $pending ? '' : beep_render_reply($comment, [], $depth, $post_id, $can_interact),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function delete_beep($request) {
|
||||
$post_id = (int) $request['id'];
|
||||
if (get_post_type($post_id) !== Beep_CPT::POST_TYPE) {
|
||||
return new WP_Error('beep_not_a_beep', 'Not a beep.', ['status' => 400]);
|
||||
}
|
||||
$deleted = wp_delete_post($post_id, true);
|
||||
if (!$deleted) {
|
||||
return new WP_Error('beep_delete_failed', 'Could not delete.', ['status' => 500]);
|
||||
}
|
||||
return rest_ensure_response(['deleted' => true]);
|
||||
}
|
||||
|
||||
public static function delete_reply($request) {
|
||||
$comment_id = (int) $request['id'];
|
||||
$deleted = wp_delete_comment($comment_id, true);
|
||||
if (!$deleted) {
|
||||
return new WP_Error('beep_delete_failed', 'Could not delete.', ['status' => 500]);
|
||||
}
|
||||
return rest_ensure_response(['deleted' => true]);
|
||||
}
|
||||
|
||||
public static function get_replies($post_id) {
|
||||
return get_comments([
|
||||
'post_id' => $post_id,
|
||||
'status' => 'approve',
|
||||
'order' => 'ASC',
|
||||
'type' => 'comment',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function reply_count($post_id) {
|
||||
return (int) get_comments_number($post_id);
|
||||
}
|
||||
}
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class Beep_Shortcode {
|
||||
|
||||
public static function init() {
|
||||
add_shortcode('beep_feed', [__CLASS__, 'render_feed']);
|
||||
add_action('rest_api_init', [__CLASS__, 'register_routes']);
|
||||
}
|
||||
|
||||
public static function register_routes() {
|
||||
register_rest_route('beep/v1', '/feed', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'fetch_feed'],
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'sort' => ['default' => 'latest'],
|
||||
'limit' => ['default' => 50, 'validate_callback' => function ($v) { return is_numeric($v); }],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* REST endpoint: re-render the feed list with the given sort.
|
||||
*/
|
||||
public static function fetch_feed($request) {
|
||||
$sort = $request->get_param('sort') === 'top' ? 'top' : 'latest';
|
||||
$limit = max(1, (int) $request->get_param('limit'));
|
||||
$posts = ($sort === 'top') ? self::get_top_beeps($limit) : self::get_latest_beeps($limit);
|
||||
|
||||
$html = '';
|
||||
foreach ($posts as $p) {
|
||||
$html .= beep_render_post($p);
|
||||
}
|
||||
if ($html === '') {
|
||||
$html = '<div class="beep-empty">No beeps yet.</div>';
|
||||
}
|
||||
return rest_ensure_response(['html' => $html, 'sort' => $sort]);
|
||||
}
|
||||
|
||||
public static function get_latest_beeps($limit) {
|
||||
global $wpdb;
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$wpdb->posts}
|
||||
WHERE post_type = %s AND post_status = 'publish'
|
||||
ORDER BY post_date DESC
|
||||
LIMIT %d",
|
||||
Beep_CPT::POST_TYPE, $limit
|
||||
));
|
||||
}
|
||||
|
||||
public static function get_top_beeps($limit) {
|
||||
global $wpdb;
|
||||
$likes = Beep_Likes::table_name();
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT p.*, COALESCE(l.cnt, 0) AS like_count
|
||||
FROM {$wpdb->posts} p
|
||||
LEFT JOIN (
|
||||
SELECT post_id, COUNT(*) AS cnt
|
||||
FROM $likes
|
||||
WHERE object_type = 'post'
|
||||
GROUP BY post_id
|
||||
) l ON l.post_id = p.ID
|
||||
WHERE p.post_type = %s AND p.post_status = 'publish'
|
||||
ORDER BY COALESCE(l.cnt, 0) DESC, p.post_date DESC
|
||||
LIMIT %d",
|
||||
Beep_CPT::POST_TYPE, $limit
|
||||
));
|
||||
}
|
||||
|
||||
public static function render_feed($atts) {
|
||||
$atts = shortcode_atts([
|
||||
'limit' => 50,
|
||||
'header' => 'Messages from',
|
||||
'sort' => 'latest',
|
||||
], $atts, 'beep_feed');
|
||||
|
||||
$limit = max(1, (int) $atts['limit']);
|
||||
$sort = ($atts['sort'] === 'top') ? 'top' : 'latest';
|
||||
$posts = ($sort === 'top') ? self::get_top_beeps($limit) : self::get_latest_beeps($limit);
|
||||
$has_posts = !empty($posts);
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="beep-feed" data-beep-feed>
|
||||
<div class="beep-feed-header">
|
||||
<span class="beep-feed-header-text"><?php echo esc_html($atts['header']); ?></span>
|
||||
<?php echo beep_logo_svg(); ?>
|
||||
</div>
|
||||
|
||||
<?php if ($has_posts) : ?>
|
||||
<div class="beep-tabs" data-beep-tabs role="tablist">
|
||||
<button type="button" class="beep-tab <?php echo $sort === 'latest' ? 'is-active' : ''; ?>"
|
||||
data-beep-sort="latest" role="tab">Latest</button>
|
||||
<button type="button" class="beep-tab <?php echo $sort === 'top' ? 'is-active' : ''; ?>"
|
||||
data-beep-sort="top" role="tab">Top</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (is_user_logged_in() && !beep_user_is_banned(get_current_user_id())) : ?>
|
||||
<?php echo beep_render_composer(); ?>
|
||||
<?php elseif (is_user_logged_in()) : ?>
|
||||
<div class="beep-notice beep-notice-banned">
|
||||
Your account has been suspended from beeping.
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="beep-notice beep-notice-login">
|
||||
<p><strong>Join in.</strong> Sign in to like and reply.</p>
|
||||
<a class="beep-button" href="<?php echo esc_url(wp_login_url(get_permalink())); ?>">Log in</a>
|
||||
<?php if (get_option('users_can_register')) : ?>
|
||||
<a class="beep-button beep-button-secondary" href="<?php echo esc_url(wp_registration_url()); ?>">Sign up</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="beep-list" data-beep-list>
|
||||
<?php
|
||||
if ($has_posts) {
|
||||
foreach ($posts as $p) {
|
||||
echo beep_render_post($p);
|
||||
}
|
||||
} else {
|
||||
echo '<div class="beep-empty">No beeps yet.</div>';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
Executable
+331
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wordmark (bird + "Beep!") shown at the top of the feed.
|
||||
*/
|
||||
function beep_logo_svg() {
|
||||
$src = esc_url(BEEP_URL . 'assets/beep-logo.png');
|
||||
return '<img class="beep-logo" src="' . $src . '" alt="Beep">';
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this user banned from posting/liking/replying?
|
||||
*/
|
||||
function beep_user_is_banned($user_id) {
|
||||
if (!$user_id) {
|
||||
return false;
|
||||
}
|
||||
return get_user_meta($user_id, 'beep_banned', true) === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the composer textarea (for logged-in, non-banned users).
|
||||
*/
|
||||
function beep_render_composer() {
|
||||
$user = wp_get_current_user();
|
||||
$avatar = get_avatar($user->ID, 48, '', $user->display_name, ['class' => 'beep-avatar']);
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="beep-compose" data-beep-compose>
|
||||
<?php echo $avatar; ?>
|
||||
<div class="beep-compose-body">
|
||||
<textarea
|
||||
class="beep-input beep-compose-input"
|
||||
data-beep-input
|
||||
placeholder="What's happening?"
|
||||
maxlength="<?php echo (int) BEEP_CHAR_LIMIT; ?>"
|
||||
rows="1"></textarea>
|
||||
<div class="beep-compose-footer">
|
||||
<span class="beep-char-counter" data-beep-counter><?php echo (int) BEEP_CHAR_LIMIT; ?></span>
|
||||
<button class="beep-button beep-post-button" data-beep-submit disabled>Beep</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single beep post + its replies tree.
|
||||
*/
|
||||
function beep_render_post($post) {
|
||||
if (!$post) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$author = get_userdata($post->post_author);
|
||||
$avatar = get_avatar($author ? $author->ID : 0, 48, '', '', ['class' => 'beep-avatar']);
|
||||
$name = $author ? esc_html($author->display_name) : 'Unknown';
|
||||
$handle = $author ? '@' . esc_html($author->user_login) : '';
|
||||
$ts = strtotime($post->post_date_gmt . ' UTC');
|
||||
$time = beep_relative_time($ts);
|
||||
$time_iso = gmdate('c', $ts);
|
||||
$time_full = mysql2date('F j, Y g:i a', $post->post_date);
|
||||
|
||||
$content = beep_format_content($post->post_content);
|
||||
$like_count = Beep_Likes::get_count($post->ID, 'post');
|
||||
$reply_count = Beep_Replies::reply_count($post->ID);
|
||||
$user_liked = Beep_Likes::user_has_liked($post->ID, 'post');
|
||||
$can_delete = current_user_can('delete_post', $post->ID);
|
||||
$can_interact = is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<article class="beep-post" data-beep-post="<?php echo (int) $post->ID; ?>">
|
||||
<?php echo $avatar; ?>
|
||||
<div class="beep-post-body">
|
||||
<div class="beep-meta">
|
||||
<span class="beep-name"><?php echo $name; ?></span>
|
||||
<?php if ($handle) : ?>
|
||||
<span class="beep-handle"><?php echo $handle; ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="beep-dot">·</span>
|
||||
<time class="beep-time"
|
||||
datetime="<?php echo esc_attr($time_iso); ?>"
|
||||
title="<?php echo esc_attr($time_full); ?>"><?php echo esc_html($time); ?></time>
|
||||
<span class="beep-spacer"></span>
|
||||
<img class="beep-post-badge" src="<?php echo esc_url(BEEP_URL . 'assets/beep-icon.png'); ?>" alt="" width="32" height="32">
|
||||
</div>
|
||||
<div class="beep-text"><?php echo $content; ?></div>
|
||||
<div class="beep-actions">
|
||||
<button class="beep-action beep-action-reply"
|
||||
data-beep-toggle-reply
|
||||
type="button"
|
||||
title="Reply">
|
||||
<?php echo beep_icon('reply'); ?>
|
||||
<span class="beep-count" data-beep-reply-count><?php echo $reply_count > 0 ? esc_html($reply_count) : ''; ?></span>
|
||||
</button>
|
||||
<button class="beep-action beep-action-like<?php echo $user_liked ? ' is-liked' : ''; ?>"
|
||||
data-beep-like-post="<?php echo (int) $post->ID; ?>"
|
||||
type="button"
|
||||
title="Like">
|
||||
<span class="beep-icon-outline"><?php echo beep_icon('heart'); ?></span>
|
||||
<span class="beep-icon-filled"><?php echo beep_icon('heart_filled'); ?></span>
|
||||
<span class="beep-count" data-beep-like-count><?php echo $like_count > 0 ? esc_html($like_count) : ''; ?></span>
|
||||
</button>
|
||||
<button class="beep-action beep-action-share"
|
||||
data-beep-share="<?php echo esc_url(get_permalink($post->ID)); ?>"
|
||||
type="button"
|
||||
title="Copy link">
|
||||
<?php echo beep_icon('share'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php if ($can_delete) : ?>
|
||||
<div class="beep-delete-row">
|
||||
<button class="beep-delete-link"
|
||||
data-beep-delete="<?php echo (int) $post->ID; ?>"
|
||||
type="button"
|
||||
aria-label="Delete this beep"
|
||||
title="Delete">×</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($can_interact) : ?>
|
||||
<?php echo beep_render_reply_form($post->ID, 0); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="beep-replies" data-beep-replies>
|
||||
<?php
|
||||
$all = Beep_Replies::get_replies($post->ID);
|
||||
echo beep_render_replies_tree($all, 0, 0, $post->ID, $can_interact);
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a reply form (used both on top-level posts and individual replies).
|
||||
* @param int $post_id The beep post ID this reply belongs to.
|
||||
* @param int $parent The parent comment ID (0 for top-level reply).
|
||||
*/
|
||||
function beep_render_reply_form($post_id, $parent) {
|
||||
ob_start();
|
||||
?>
|
||||
<div class="beep-reply-form" data-beep-reply-form data-beep-parent="<?php echo (int) $parent; ?>" hidden>
|
||||
<textarea
|
||||
class="beep-input beep-reply-input"
|
||||
placeholder="Post your reply"
|
||||
maxlength="<?php echo (int) BEEP_CHAR_LIMIT; ?>"
|
||||
rows="1"></textarea>
|
||||
<div class="beep-reply-footer">
|
||||
<span class="beep-char-counter"><?php echo (int) BEEP_CHAR_LIMIT; ?></span>
|
||||
<button class="beep-button beep-reply-submit"
|
||||
data-beep-reply-submit="<?php echo (int) $post_id; ?>"
|
||||
data-beep-reply-parent="<?php echo (int) $parent; ?>"
|
||||
type="button"
|
||||
disabled>Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively render replies. Visual indent caps at depth 2.
|
||||
*/
|
||||
function beep_render_replies_tree($comments, $parent_id, $depth, $post_id, $can_interact) {
|
||||
$out = '';
|
||||
foreach ($comments as $c) {
|
||||
if ((int) $c->comment_parent !== (int) $parent_id) {
|
||||
continue;
|
||||
}
|
||||
$out .= beep_render_reply($c, $comments, $depth, $post_id, $can_interact);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single reply, plus its nested children.
|
||||
*/
|
||||
function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id = 0, $can_interact = false) {
|
||||
if (!$comment) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$author_id = (int) $comment->user_id;
|
||||
$avatar = $author_id
|
||||
? get_avatar($author_id, 36, '', '', ['class' => 'beep-avatar beep-avatar-sm'])
|
||||
: get_avatar($comment->comment_author_email, 36, '', '', ['class' => 'beep-avatar beep-avatar-sm']);
|
||||
|
||||
$author = $author_id ? get_userdata($author_id) : null;
|
||||
$name = $author ? esc_html($author->display_name) : esc_html($comment->comment_author);
|
||||
$handle = $author ? '@' . esc_html($author->user_login) : '';
|
||||
|
||||
$ts = strtotime($comment->comment_date_gmt . ' UTC');
|
||||
$time = beep_relative_time($ts);
|
||||
$time_iso = gmdate('c', $ts);
|
||||
|
||||
$content = beep_format_content($comment->comment_content);
|
||||
$can_delete = current_user_can('moderate_comments');
|
||||
$like_count = Beep_Likes::get_count($comment->comment_ID, 'reply');
|
||||
$user_liked = Beep_Likes::user_has_liked($comment->comment_ID, 'reply');
|
||||
|
||||
$depth_class = 'beep-depth-' . min((int) $depth, 2);
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div class="beep-reply <?php echo esc_attr($depth_class); ?>" data-beep-reply="<?php echo (int) $comment->comment_ID; ?>">
|
||||
<?php echo $avatar; ?>
|
||||
<div class="beep-reply-body">
|
||||
<div class="beep-meta">
|
||||
<span class="beep-name"><?php echo $name; ?></span>
|
||||
<?php if ($handle) : ?>
|
||||
<span class="beep-handle"><?php echo $handle; ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="beep-dot">·</span>
|
||||
<time class="beep-time" datetime="<?php echo esc_attr($time_iso); ?>"><?php echo esc_html($time); ?></time>
|
||||
<span class="beep-spacer"></span>
|
||||
<img class="beep-post-badge beep-post-badge-sm" src="<?php echo esc_url(BEEP_URL . 'assets/beep-icon.png'); ?>" alt="" width="24" height="24">
|
||||
</div>
|
||||
<div class="beep-text"><?php echo $content; ?></div>
|
||||
|
||||
<?php if ($can_interact) : ?>
|
||||
<div class="beep-actions beep-actions-reply">
|
||||
<button class="beep-action beep-action-reply"
|
||||
data-beep-toggle-reply
|
||||
type="button"
|
||||
title="Reply">
|
||||
<?php echo beep_icon('reply'); ?>
|
||||
</button>
|
||||
<button class="beep-action beep-action-like<?php echo $user_liked ? ' is-liked' : ''; ?>"
|
||||
data-beep-like-reply="<?php echo (int) $comment->comment_ID; ?>"
|
||||
type="button"
|
||||
title="Like">
|
||||
<span class="beep-icon-outline"><?php echo beep_icon('heart'); ?></span>
|
||||
<span class="beep-icon-filled"><?php echo beep_icon('heart_filled'); ?></span>
|
||||
<span class="beep-count" data-beep-like-count><?php echo $like_count > 0 ? esc_html($like_count) : ''; ?></span>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo beep_render_reply_form($post_id, $comment->comment_ID); ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($can_delete) : ?>
|
||||
<div class="beep-delete-row">
|
||||
<button class="beep-delete-link"
|
||||
data-beep-delete-reply="<?php echo (int) $comment->comment_ID; ?>"
|
||||
type="button"
|
||||
aria-label="Delete this reply"
|
||||
title="Delete">×</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// Recursively render children
|
||||
$children = beep_render_replies_tree($all_comments, $comment->comment_ID, $depth + 1, $post_id, $can_interact);
|
||||
if ($children) {
|
||||
echo '<div class="beep-replies beep-replies-nested" data-beep-replies>' . $children . '</div>';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format beep text: linkify URLs, @mentions, #hashtags.
|
||||
*/
|
||||
function beep_format_content($text) {
|
||||
$text = wp_kses($text, []);
|
||||
// Hashtags
|
||||
$text = preg_replace_callback('/(^|\s)#([\p{L}0-9_]+)/u', function ($m) {
|
||||
return $m[1] . '<a href="#" class="beep-tag">#' . esc_html($m[2]) . '</a>';
|
||||
}, $text);
|
||||
// @mentions
|
||||
$text = preg_replace_callback('/(^|\s)@([a-zA-Z0-9_]+)/', function ($m) {
|
||||
$user = get_user_by('login', $m[2]);
|
||||
if ($user) {
|
||||
return $m[1] . '<a href="' . esc_url(get_author_posts_url($user->ID)) . '" class="beep-mention">@' . esc_html($m[2]) . '</a>';
|
||||
}
|
||||
return $m[1] . '<span class="beep-mention beep-mention-stale">@' . esc_html($m[2]) . '</span>';
|
||||
}, $text);
|
||||
// URLs
|
||||
$text = preg_replace_callback('#(https?://[^\s<]+)#i', function ($m) {
|
||||
$url = esc_url($m[1]);
|
||||
$display = parse_url($url, PHP_URL_HOST);
|
||||
if (!$display) {
|
||||
$display = $url;
|
||||
}
|
||||
return '<a href="' . $url . '" class="beep-link" target="_blank" rel="noopener nofollow">' . esc_html($display) . '</a>';
|
||||
}, $text);
|
||||
// Line breaks
|
||||
$text = nl2br($text);
|
||||
return $text;
|
||||
}
|
||||
|
||||
function beep_relative_time($timestamp) {
|
||||
$now = time();
|
||||
$diff = $now - $timestamp;
|
||||
if ($diff < 0) {
|
||||
$diff = 0;
|
||||
}
|
||||
if ($diff < 60) {
|
||||
return $diff . 's';
|
||||
}
|
||||
if ($diff < 3600) {
|
||||
return floor($diff / 60) . 'm';
|
||||
}
|
||||
if ($diff < 86400) {
|
||||
return floor($diff / 3600) . 'h';
|
||||
}
|
||||
if ($diff < 604800) {
|
||||
return floor($diff / 86400) . 'd';
|
||||
}
|
||||
return gmdate('M j', $timestamp);
|
||||
}
|
||||
|
||||
function beep_icon($name) {
|
||||
$icons = [
|
||||
'reply' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01z"/></svg>',
|
||||
'heart' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"/></svg>',
|
||||
'heart_filled' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M20.884 13.19c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"/></svg>',
|
||||
'share' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z"/></svg>',
|
||||
];
|
||||
return isset($icons[$name]) ? $icons[$name] : '';
|
||||
}
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
=== Beep ===
|
||||
Contributors: sami
|
||||
Tags: microblog, twitter, tweets, social, comments
|
||||
Requires at least: 6.0
|
||||
Tested up to: 6.7
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 1.4.0
|
||||
License: GPLv2 or later
|
||||
|
||||
A Twitter-style microblog for WordPress. Logged-in users post short updates,
|
||||
like, and reply (with nested threads). Top feed sorts by likes.
|
||||
|
||||
== Description ==
|
||||
|
||||
Beep adds a Twitter-style microblog to your WordPress site. Drop the
|
||||
[beep_feed] shortcode on any page. Likes, threaded replies, and Latest/Top
|
||||
sort tabs are all built in.
|
||||
|
||||
== Installation ==
|
||||
|
||||
1. Upload beep.zip via Plugins -> Add New -> Upload Plugin. Activate.
|
||||
2. Add [beep_feed] to a page. Visit it.
|
||||
3. For multi-user signup, enable "Anyone can register" in Settings -> General
|
||||
and install the free "Passwordless Login" plugin for magic-link sign-in.
|
||||
|
||||
== Shortcode ==
|
||||
|
||||
[beep_feed limit="50" header="Messages from" sort="latest"]
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 1.4.0 =
|
||||
* Like replies, not just beeps.
|
||||
* Nested replies (reply to replies, indented up to two levels).
|
||||
* Latest / Top sort tabs at the top of the feed.
|
||||
* Bird badge in the top-right of every beep and reply.
|
||||
* Polished tabs, focus states, action button styling.
|
||||
|
||||
= 1.3.0 =
|
||||
* Feed header reads "Messages from" + Beep wordmark.
|
||||
* Avatar/textarea fixes baked in (no Additional CSS needed).
|
||||
|
||||
= 1.2.0 =
|
||||
* Feed header shows Beep wordmark instead of small icon + Home text.
|
||||
|
||||
= 1.1.0 =
|
||||
* New hand-drawn bird logo.
|
||||
|
||||
= 1.0.0 =
|
||||
* Initial release.
|
||||
Reference in New Issue
Block a user