@@ -303,7 +303,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
draft = current_draft(socket.assigns, post, metadata, active_language)
|
draft = current_draft(socket.assigns, post, metadata, active_language)
|
||||||
normalized = normalize_list_entry(value)
|
normalized = normalize_list_entry(value)
|
||||||
|
|
||||||
if normalized in [nil, ""] do
|
if normalized == "" do
|
||||||
socket
|
socket
|
||||||
else
|
else
|
||||||
ensure_list_value(post.project_id, kind, normalized)
|
ensure_list_value(post.project_id, kind, normalized)
|
||||||
@@ -414,8 +414,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
|
|
||||||
def build(_assigns), do: nil
|
def build(_assigns), do: nil
|
||||||
|
|
||||||
def normalize_mode(mode) when mode in [:visual, :markdown, :preview], do: mode
|
def normalize_mode(mode) when mode in [:markdown, :preview], do: mode
|
||||||
def normalize_mode("visual"), do: :visual
|
def normalize_mode("visual"), do: :markdown
|
||||||
def normalize_mode("preview"), do: :preview
|
def normalize_mode("preview"), do: :preview
|
||||||
def normalize_mode(_mode), do: :markdown
|
def normalize_mode(_mode), do: :markdown
|
||||||
|
|
||||||
@@ -507,7 +507,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
def post_editor_save_state_label(:discarded), do: translated("Reverted")
|
def post_editor_save_state_label(:discarded), do: translated("Reverted")
|
||||||
def post_editor_save_state_label(_state), do: translated("Idle")
|
def post_editor_save_state_label(_state), do: translated("Idle")
|
||||||
|
|
||||||
def post_editor_mode_label(:visual), do: translated("Visual")
|
|
||||||
def post_editor_mode_label(:markdown), do: translated("Markdown")
|
def post_editor_mode_label(:markdown), do: translated("Markdown")
|
||||||
def post_editor_mode_label(:preview), do: translated("Preview")
|
def post_editor_mode_label(:preview), do: translated("Preview")
|
||||||
|
|
||||||
@@ -687,7 +686,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
%{
|
%{
|
||||||
"title" => post.title || "",
|
"title" => post.title || "",
|
||||||
"excerpt" => post.excerpt || "",
|
"excerpt" => post.excerpt || "",
|
||||||
"content" => post.content || "",
|
"content" => Posts.editor_body(post),
|
||||||
"tags" => Enum.join(post.tags || [], ", "),
|
"tags" => Enum.join(post.tags || [], ", "),
|
||||||
"categories" => Enum.join(post.categories || [], ", "),
|
"categories" => Enum.join(post.categories || [], ", "),
|
||||||
"author" => post.author || metadata.default_author || "",
|
"author" => post.author || metadata.default_author || "",
|
||||||
@@ -699,7 +698,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
|||||||
%{
|
%{
|
||||||
"title" => translation && translation.title || "",
|
"title" => translation && translation.title || "",
|
||||||
"excerpt" => translation && translation.excerpt || "",
|
"excerpt" => translation && translation.excerpt || "",
|
||||||
"content" => translation && translation.content || "",
|
"content" => if(translation, do: Posts.editor_body(translation), else: ""),
|
||||||
"tags" => Enum.join(post.tags || [], ", "),
|
"tags" => Enum.join(post.tags || [], ", "),
|
||||||
"categories" => Enum.join(post.categories || [], ", "),
|
"categories" => Enum.join(post.categories || [], ", "),
|
||||||
"author" => post.author || metadata.default_author || "",
|
"author" => post.author || metadata.default_author || "",
|
||||||
|
|||||||
@@ -327,7 +327,7 @@
|
|||||||
|
|
||||||
<div class="editor-toolbar-center">
|
<div class="editor-toolbar-center">
|
||||||
<div class="editor-mode-toggle">
|
<div class="editor-mode-toggle">
|
||||||
<%= for mode <- [:visual, :markdown, :preview] do %>
|
<%= for mode <- [:markdown, :preview] do %>
|
||||||
<button
|
<button
|
||||||
class={if(@post_editor.mode == mode, do: "active")}
|
class={if(@post_editor.mode == mode, do: "active")}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -386,7 +386,10 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<textarea id={"post-editor-content-#{@post_editor.id}"} class="post-editor-textarea post-editor-content" data-testid="post-editor-content" phx-hook="PostEditorContent" data-post-editor-id={@post_editor.id} name="post_editor[content]" rows="18"><%= @post_editor.form["content"] %></textarea>
|
<div id={"post-editor-markdown-surface-#{@post_editor.id}"} class="post-editor-markdown-surface" data-testid="post-editor-markdown-surface" phx-hook="PostEditorContent" data-post-editor-id={@post_editor.id}>
|
||||||
|
<pre class="post-editor-markdown-highlight" aria-hidden="true"></pre>
|
||||||
|
<textarea id={"post-editor-content-#{@post_editor.id}"} class="post-editor-textarea post-editor-content" data-testid="post-editor-content" data-post-editor-id={@post_editor.id} name="post_editor[content]" rows="18"><%= @post_editor.form["content"] %></textarea>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -215,6 +215,30 @@ defmodule BDS.Posts do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def editor_body(%Post{content: content}) when is_binary(content), do: content
|
||||||
|
|
||||||
|
def editor_body(%Post{project_id: project_id, file_path: file_path})
|
||||||
|
when is_binary(file_path) and file_path != "" do
|
||||||
|
project_id
|
||||||
|
|> Projects.get_project!()
|
||||||
|
|> Projects.project_data_dir()
|
||||||
|
|> Path.join(file_path)
|
||||||
|
|> read_markdown_body()
|
||||||
|
end
|
||||||
|
|
||||||
|
def editor_body(%Translation{content: content}) when is_binary(content), do: content
|
||||||
|
|
||||||
|
def editor_body(%Translation{project_id: project_id, file_path: file_path})
|
||||||
|
when is_binary(file_path) and file_path != "" do
|
||||||
|
project_id
|
||||||
|
|> Projects.get_project!()
|
||||||
|
|> Projects.project_data_dir()
|
||||||
|
|> Path.join(file_path)
|
||||||
|
|> read_markdown_body()
|
||||||
|
end
|
||||||
|
|
||||||
|
def editor_body(_record), do: ""
|
||||||
|
|
||||||
def delete_post(post_id) do
|
def delete_post(post_id) do
|
||||||
case Repo.get(Post, post_id) do
|
case Repo.get(Post, post_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -664,8 +688,10 @@ defmodule BDS.Posts do
|
|||||||
defp published_post_body(%Post{content: content}, _full_path) when is_binary(content),
|
defp published_post_body(%Post{content: content}, _full_path) when is_binary(content),
|
||||||
do: content
|
do: content
|
||||||
|
|
||||||
defp published_post_body(_post, full_path) do
|
defp published_post_body(_post, full_path), do: read_markdown_body(full_path)
|
||||||
case File.read(full_path) do
|
|
||||||
|
defp read_markdown_body(path) do
|
||||||
|
case File.read(path) do
|
||||||
{:ok, contents} ->
|
{:ok, contents} ->
|
||||||
case String.split(contents, "\n---\n", parts: 2) do
|
case String.split(contents, "\n---\n", parts: 2) do
|
||||||
[_frontmatter, body] -> String.trim_trailing(body, "\n")
|
[_frontmatter, body] -> String.trim_trailing(body, "\n")
|
||||||
|
|||||||
@@ -1606,12 +1606,100 @@ button svg * {
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-editor .post-editor-content {
|
.post-editor .post-editor-markdown-surface {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 380px;
|
min-height: 380px;
|
||||||
resize: none;
|
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-markdown-highlight,
|
||||||
|
.post-editor .post-editor-content {
|
||||||
font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace);
|
font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||||
font-size: var(--vscode-editor-font-size, 13px);
|
font-size: var(--vscode-editor-font-size, 13px);
|
||||||
|
line-height: 1.6;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-markdown-highlight {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-markdown-surface.is-enhanced .post-editor-markdown-highlight {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 380px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-markdown-surface.is-enhanced .post-editor-content {
|
||||||
|
color: transparent;
|
||||||
|
caret-color: var(--vscode-editor-foreground);
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-markdown-surface.is-enhanced .post-editor-content::selection {
|
||||||
|
background: rgba(97, 175, 239, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .md-heading-marker,
|
||||||
|
.post-editor .md-list-marker,
|
||||||
|
.post-editor .md-quote-marker,
|
||||||
|
.post-editor .md-fence,
|
||||||
|
.post-editor .token-keyword {
|
||||||
|
color: #c678dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .md-heading-text,
|
||||||
|
.post-editor .token-module {
|
||||||
|
color: #61afef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .md-inline-code,
|
||||||
|
.post-editor .token-string {
|
||||||
|
color: #98c379;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .md-link,
|
||||||
|
.post-editor .token-atom {
|
||||||
|
color: #56b6c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .md-emphasis,
|
||||||
|
.post-editor .md-strong,
|
||||||
|
.post-editor .token-number {
|
||||||
|
color: #e5c07b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .md-quote-text,
|
||||||
|
.post-editor .token-comment {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .md-code-line {
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-editor .editor-footer {
|
.post-editor .editor-footer {
|
||||||
|
|||||||
146
priv/ui/live.js
146
priv/ui/live.js
@@ -155,6 +155,92 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (value) =>
|
||||||
|
String(value || "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
const stashTokens = (source, pattern, className, tokens) =>
|
||||||
|
source.replace(pattern, (match) => {
|
||||||
|
const marker = `@@token_${tokens.length}@@`;
|
||||||
|
tokens.push(`<span class="${className}">${match}</span>`);
|
||||||
|
return marker;
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoreTokens = (source, tokens) =>
|
||||||
|
tokens.reduce((html, token, index) => html.replaceAll(`@@token_${index}@@`, token), source);
|
||||||
|
|
||||||
|
const highlightCodeLine = (line, language) => {
|
||||||
|
const tokens = [];
|
||||||
|
let html = escapeHtml(line);
|
||||||
|
|
||||||
|
html = stashTokens(html, /#.*/g, "token-comment", tokens);
|
||||||
|
html = stashTokens(html, /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, "token-string", tokens);
|
||||||
|
html = html.replace(/\b\d+(?:\.\d+)?\b/g, '<span class="token-number">$&</span>');
|
||||||
|
|
||||||
|
if (language === "elixir") {
|
||||||
|
html = html.replace(/:\w+[!?]?/g, '<span class="token-atom">$&</span>');
|
||||||
|
html = html.replace(
|
||||||
|
/\b(?:defp?|do|end|fn|case|cond|if|else|with|when|receive|after|rescue|catch|try|use|alias|import|require|quote|unquote|for|in)\b/g,
|
||||||
|
'<span class="token-keyword">$&</span>'
|
||||||
|
);
|
||||||
|
html = html.replace(/\b[A-Z][A-Za-z0-9_.]*\b/g, '<span class="token-module">$&</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<span class="md-code-line">${restoreTokens(html, tokens)}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightMarkdownLine = (line) => {
|
||||||
|
let html = escapeHtml(line);
|
||||||
|
|
||||||
|
if (/^\s{0,3}#{1,6}\s/.test(line)) {
|
||||||
|
html = html.replace(/^((?:\s{0,3}#{1,6}))(\s+)(.*)$/, '<span class="md-heading-marker">$1</span>$2<span class="md-heading-text">$3</span>');
|
||||||
|
} else if (/^\s*>\s?/.test(html)) {
|
||||||
|
html = html.replace(/^(\s*>)(\s?)(.*)$/, '<span class="md-quote-marker">$1</span>$2<span class="md-quote-text">$3</span>');
|
||||||
|
} else if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) {
|
||||||
|
html = html.replace(/^(\s*(?:[-*+]|\d+\.))(\s+)(.*)$/, '<span class="md-list-marker">$1</span>$2<span class="md-list-text">$3</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
html = html.replace(/(`[^`]+`)/g, '<span class="md-inline-code">$1</span>');
|
||||||
|
html = html.replace(/(\[[^\]]+\]\([^\)]+\))/g, '<span class="md-link">$1</span>');
|
||||||
|
html = html.replace(/(\*\*[^*]+\*\*)/g, '<span class="md-strong">$1</span>');
|
||||||
|
html = html.replace(/(^|[^*])(\*[^*\n]+\*)/g, '$1<span class="md-emphasis">$2</span>');
|
||||||
|
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightMarkdownSource = (source) => {
|
||||||
|
const lines = String(source || "").split("\n");
|
||||||
|
let inFence = false;
|
||||||
|
let fenceLanguage = "";
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line) => {
|
||||||
|
const trimmed = line.trimStart();
|
||||||
|
|
||||||
|
if (trimmed.startsWith("```")) {
|
||||||
|
const nextLanguage = trimmed.slice(3).trim().toLowerCase();
|
||||||
|
const highlightedFence = `<span class="md-fence">${escapeHtml(line)}</span>`;
|
||||||
|
|
||||||
|
if (!inFence) {
|
||||||
|
inFence = true;
|
||||||
|
fenceLanguage = nextLanguage;
|
||||||
|
} else {
|
||||||
|
inFence = false;
|
||||||
|
fenceLanguage = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlightedFence;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inFence ? highlightCodeLine(line, fenceLanguage) : highlightMarkdownLine(line);
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
const Hooks = {
|
const Hooks = {
|
||||||
AppShell: {
|
AppShell: {
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -375,30 +461,68 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
PostEditorContent: {
|
PostEditorContent: {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.handleInsert = ({ id, content }) => {
|
this.textarea = this.el.querySelector("textarea");
|
||||||
if (!content || String(id) !== String(this.el.dataset.postEditorId)) {
|
this.highlight = this.el.querySelector(".post-editor-markdown-highlight");
|
||||||
|
|
||||||
|
this.renderHighlight = () => {
|
||||||
|
if (!this.textarea || !this.highlight) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = this.el.selectionStart ?? this.el.value.length;
|
this.highlight.innerHTML = `${highlightMarkdownSource(this.textarea.value)}\n`;
|
||||||
const end = this.el.selectionEnd ?? start;
|
this.highlight.scrollTop = this.textarea.scrollTop;
|
||||||
const before = this.el.value.slice(0, start);
|
this.highlight.scrollLeft = this.textarea.scrollLeft;
|
||||||
const after = this.el.value.slice(end);
|
};
|
||||||
|
|
||||||
|
this.handleInput = () => this.renderHighlight();
|
||||||
|
this.handleScroll = () => {
|
||||||
|
if (!this.textarea || !this.highlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.highlight.scrollTop = this.textarea.scrollTop;
|
||||||
|
this.highlight.scrollLeft = this.textarea.scrollLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleInsert = ({ id, content }) => {
|
||||||
|
if (!this.textarea || !content || String(id) !== String(this.el.dataset.postEditorId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = this.textarea.selectionStart ?? this.textarea.value.length;
|
||||||
|
const end = this.textarea.selectionEnd ?? start;
|
||||||
|
const before = this.textarea.value.slice(0, start);
|
||||||
|
const after = this.textarea.value.slice(end);
|
||||||
const separator = before !== "" && !before.endsWith("\n") ? "\n" : "";
|
const separator = before !== "" && !before.endsWith("\n") ? "\n" : "";
|
||||||
const suffix = after !== "" && !content.endsWith("\n") ? "\n" : "";
|
const suffix = after !== "" && !content.endsWith("\n") ? "\n" : "";
|
||||||
const inserted = `${separator}${content}${suffix}`;
|
const inserted = `${separator}${content}${suffix}`;
|
||||||
const nextValue = `${before}${inserted}${after}`;
|
const nextValue = `${before}${inserted}${after}`;
|
||||||
|
|
||||||
this.el.focus();
|
this.textarea.focus();
|
||||||
this.el.value = nextValue;
|
this.textarea.value = nextValue;
|
||||||
|
|
||||||
const caret = before.length + inserted.length;
|
const caret = before.length + inserted.length;
|
||||||
this.el.setSelectionRange(caret, caret);
|
this.textarea.setSelectionRange(caret, caret);
|
||||||
this.el.dispatchEvent(new Event("input", { bubbles: true }));
|
this.textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
this.el.dispatchEvent(new Event("change", { bubbles: true }));
|
this.textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.el.classList.add("is-enhanced");
|
||||||
|
this.textarea?.addEventListener("input", this.handleInput);
|
||||||
|
this.textarea?.addEventListener("scroll", this.handleScroll);
|
||||||
this.handleEvent("post-editor-insert-content", this.handleInsert);
|
this.handleEvent("post-editor-insert-content", this.handleInsert);
|
||||||
|
this.renderHighlight();
|
||||||
|
},
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
this.textarea = this.el.querySelector("textarea");
|
||||||
|
this.highlight = this.el.querySelector(".post-editor-markdown-highlight");
|
||||||
|
this.renderHighlight();
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
this.textarea?.removeEventListener("input", this.handleInput);
|
||||||
|
this.textarea?.removeEventListener("scroll", this.handleScroll);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -699,6 +699,34 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert discarded_post.title == "Updated Shell Post"
|
assert discarded_post.title == "Updated Shell Post"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "published post editor loads body from file and renders markdown-only editor", %{project: project} do
|
||||||
|
{:ok, post} =
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Published Editor Post",
|
||||||
|
content: "# Heading\n\n```elixir\nIO.puts(:ok)\n```\n",
|
||||||
|
excerpt: "Published excerpt"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _published} = Posts.publish_post(post.id)
|
||||||
|
published = Posts.get_post!(post.id)
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => published.id,
|
||||||
|
"title" => published.title,
|
||||||
|
"subtitle" => "published"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert html =~ ~s(data-testid="post-editor-content")
|
||||||
|
assert Regex.match?(~r/name="post_editor\[content\]"[^>]*># Heading\s+```elixir\s+IO\.puts\(:ok\)\s+```/s, html)
|
||||||
|
assert html =~ "post-editor-markdown-surface"
|
||||||
|
refute html =~ ~s(phx-value-mode="visual")
|
||||||
|
end
|
||||||
|
|
||||||
defp seed_sidebar_posts(project_id) do
|
defp seed_sidebar_posts(project_id) do
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user