Compare commits

..

2 Commits

16 changed files with 544 additions and 39 deletions

View File

@@ -734,3 +734,47 @@ html, body {
border-radius: var(--mx-radius-sm);
display: block;
}
/* ── Context Menu ── */
.mx-context-menu {
position: fixed;
z-index: 300;
min-width: 160px;
background: var(--mx-surface2);
border: 1px solid var(--mx-border2);
border-radius: var(--mx-radius-sm);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.2);
overflow: hidden;
padding: 4px 0;
animation: mx-ctx-in 0.1s ease;
}
@keyframes mx-ctx-in {
from { opacity: 0; transform: scale(0.96) translateY(-4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.mx-context-menu-item {
display: flex;
align-items: center;
width: 100%;
padding: 0.45rem 0.875rem;
background: none;
border: none;
color: var(--mx-fg);
font-size: 0.875rem;
font-family: inherit;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.mx-context-menu-item:hover {
background: color-mix(in oklch, var(--mx-accent) 14%, transparent);
}
.mx-context-menu-separator {
height: 1px;
background: var(--mx-border);
margin: 4px 0;
}

View File

@@ -2,7 +2,7 @@
// Do not edit this file manually
import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField } from "./ash_types";
import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField, usersFilterInput, usersResourceSchema, usersSortField } from "./ash_types";
export type * from "./ash_types";
// Helper Functions
@@ -201,6 +201,107 @@ export async function executeValidationRpcRequest<T>(
export type ReadUserFields = UnifiedFieldSelection<usersResourceSchema>[];
export type InferReadUserResult<
Fields extends ReadUserFields | undefined,
Page extends ReadUserConfig["page"] = undefined
> = ConditionalPaginatedResultMixed<Page, Array<InferResult<usersResourceSchema, Fields>>, {
results: Array<InferResult<usersResourceSchema, Fields>>;
hasMore: boolean;
limit: number;
offset: number;
count?: number | null;
type: "offset";
}, {
results: Array<InferResult<usersResourceSchema, Fields>>;
hasMore: boolean;
limit: number;
after: string | null;
before: string | null;
previousPage: string;
nextPage: string;
count?: number | null;
type: "keyset";
}>;
export type ReadUserConfig = {
tenant?: string;
fields: ReadUserFields;
filter?: usersFilterInput;
sort?: SortString<usersSortField> | SortString<usersSortField>[];
page?: (
{
limit?: number;
offset?: number;
count?: boolean;
} | {
limit?: number;
after?: string;
before?: string;
}
);
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
};
export type ReadUserResult<Fields extends ReadUserFields, Page extends ReadUserConfig["page"] = undefined> = | { success: true; data: InferReadUserResult<Fields, Page>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Read User records
*
* @ashActionType :read
*/
export async function readUser<Fields extends ReadUserFields, Config extends ReadUserConfig = ReadUserConfig>(
config: Config & { fields: Fields }
): Promise<ReadUserResult<Fields, Config["page"]>> {
const payload = {
action: "read_user",
...(config.tenant !== undefined && { tenant: config.tenant }),
...(config.fields !== undefined && { fields: config.fields }),
...(config.filter && { filter: config.filter }),
...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort }),
...(config.page && { page: config.page })
};
return executeActionRpcRequest<ReadUserResult<Fields, Config["page"]>>(
payload,
config
);
}
/**
* Validate: Read User records
*
* @ashActionType :read
* @validation true
*/
export async function validateReadUser(
config: {
tenant?: string;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "read_user",
...(config.tenant !== undefined && { tenant: config.tenant })
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[];

View File

@@ -6,6 +6,24 @@
export type UUID = string;
export type UtcDateTimeUsec = string;
// users Schema
export type usersResourceSchema = {
__type: "Resource";
__primitiveFields: "id" | "email";
id: UUID;
email: string;
};
export type usersAttributesOnlySchema = {
__type: "Resource";
__primitiveFields: "id" | "email";
id: UUID;
email: string;
};
// media Schema
export type mediaResourceSchema = {
__type: "Resource";
@@ -14,6 +32,7 @@ export type mediaResourceSchema = {
s3Key: string;
userId: UUID;
tweetId: UUID | null;
user: { __type: "Relationship"; __resource: usersResourceSchema; };
tweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
};
@@ -41,6 +60,7 @@ export type tweetsResourceSchema = {
state: "posted" | "drafted";
likedByMe: boolean;
userEmail: string | null;
user: { __type: "Relationship"; __resource: usersResourceSchema; };
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
};
@@ -58,6 +78,26 @@ export type tweetsAttributesOnlySchema = {
};
export type usersFilterInput = {
and?: Array<usersFilterInput>;
or?: Array<usersFilterInput>;
not?: Array<usersFilterInput>;
id?: {
eq?: UUID;
notEq?: UUID;
in?: Array<UUID>;
};
email?: {
eq?: string;
notEq?: string;
in?: Array<string>;
};
};
export type mediaFilterInput = {
and?: Array<mediaFilterInput>;
or?: Array<mediaFilterInput>;
@@ -89,6 +129,8 @@ export type mediaFilterInput = {
};
user?: usersFilterInput;
tweet?: tweetsFilterInput;
};
@@ -154,11 +196,16 @@ export type tweetsFilterInput = {
isNil?: boolean;
};
user?: usersFilterInput;
media?: mediaFilterInput;
};
export const usersFilterFields = ["id", "email"] as const;
export type usersFilterField = (typeof usersFilterFields)[number];
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
export type mediaFilterField = (typeof mediaFilterFields)[number];
@@ -166,6 +213,9 @@ export const tweetsFilterFields = ["id", "content", "likes", "userId", "inserted
export type tweetsFilterField = (typeof tweetsFilterFields)[number];
export const usersSortFields = ["id", "email"] as const;
export type usersSortField = (typeof usersSortFields)[number];
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
export type mediaSortField = (typeof mediaSortFields)[number];

View File

@@ -15,6 +15,7 @@ import {
likeTweet,
unlikeTweet,
updateTweet,
readUser,
buildCSRFHeaders,
} from "./ash_rpc";
import { uploadFile } from "./upload";
@@ -25,6 +26,7 @@ const queryClient = new QueryClient({
// ── Types ──────────────────────────────────────────────────────────────────────
type User = { id: string; email: string };
type MediaItem = { id: string; s3Key: string };
type Tweet = {
id: string;
@@ -53,6 +55,75 @@ function getAssetHost(): string {
return appEl?.dataset.assetHost ?? "http://localhost:9000";
}
// ── Context menu ──────────────────────────────────────────────────────────────
type ContextMenuItem =
| { type: "item"; label: string; onClick: () => void }
| { type: "separator" };
function ContextMenu({
x,
y,
items,
onClose,
}: {
x: number;
y: number;
items: ContextMenuItem[];
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
const itemCount = items.filter((i) => i.type === "item").length;
const sepCount = items.filter((i) => i.type === "separator").length;
const menuH = itemCount * 34 + sepCount * 9 + 8;
const menuW = 180;
const left = Math.min(x, window.innerWidth - menuW - 8);
const top = Math.min(y, window.innerHeight - menuH - 8);
return createPortal(
<div
ref={ref}
className="mx-context-menu"
style={{ left, top }}
onContextMenu={(e) => e.preventDefault()}
>
{items.map((item, i) =>
item.type === "separator" ? (
<div key={i} className="mx-context-menu-separator" />
) : (
<button
key={i}
className="mx-context-menu-item"
onClick={() => {
item.onClick();
onClose();
}}
>
{item.label}
</button>
)
)}
</div>,
document.body
);
}
// ── Components ─────────────────────────────────────────────────────────────────
function Spinner() {
@@ -317,8 +388,43 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
const [editText, setEditText] = useState(tweet.content);
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const qc = useQueryClient();
const tweetUrl = `${window.location.origin}/feed/${tweet.id}`;
const ctxItems: ContextMenuItem[] = canModify
? [
{
type: "item",
label: "Edit",
onClick: () => {
setEditText(tweet.content);
setEditing(true);
setConfirmDelete(false);
},
},
{ type: "separator" },
{
type: "item",
label: "Share",
onClick: () => navigator.clipboard.writeText(tweetUrl),
},
]
: [
{
type: "item",
label: "View",
onClick: () => { window.location.href = tweetUrl; },
},
{ type: "separator" },
{
type: "item",
label: "Share",
onClick: () => navigator.clipboard.writeText(tweetUrl),
},
];
const deleteMutation = useMutation({
mutationFn: async () => {
const res = await destroyTweet({
@@ -377,6 +483,7 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
className="mx-tweet"
style={{ cursor: "pointer" }}
onClick={() => { window.location.href = `/feed/${tweet.id}`; }}
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
>
<div className="mx-tweet-avatar">
<span>M</span>
@@ -490,6 +597,14 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
{error && !editing && <p className="mx-compose-error">{error}</p>}
</div>
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxItems}
onClose={() => setCtxMenu(null)}
/>
)}
</article>
);
}
@@ -772,54 +887,166 @@ function RefreshButton() {
);
}
function UserCard({ user }: { user: User }) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const userUrl = `${window.location.origin}/users/${user.id}`;
const ctxItems: ContextMenuItem[] = [
{
type: "item",
label: "Share",
onClick: () => navigator.clipboard.writeText(userUrl),
},
];
return (
<article
className="mx-tweet"
style={{ cursor: "pointer" }}
onClick={() => { window.location.href = `/users/${user.id}`; }}
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
>
<div className="mx-tweet-avatar">
<span>M</span>
</div>
<div className="mx-tweet-body">
<div className="mx-tweet-header">
<span className="mx-tweet-handle">{user.email}</span>
</div>
</div>
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxItems}
onClose={() => setCtxMenu(null)}
/>
)}
</article>
);
}
function UserList() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ["users"],
queryFn: async () => {
const res = await readUser({
fields: ["id", "email"],
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error("Failed to load users");
const users = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
return users as User[];
},
});
if (isLoading) return <Spinner />;
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load users"} />;
const users = data ?? [];
if (users.length === 0) {
return (
<div className="mx-empty">
<div className="mx-empty-icon"></div>
<p className="mx-empty-title">No users yet</p>
<p className="mx-empty-sub">Be the first to sign up.</p>
</div>
);
}
return (
<div className="mx-feed">
{users.map((u) => (
<UserCard key={u.id} user={u} />
))}
</div>
);
}
function UserDetail({ userId }: { userId: string }) {
const { data: user, isLoading, isError } = useQuery({
queryKey: ["user", userId],
queryFn: async () => {
const res = await readUser({
fields: ["id", "email"],
filter: { id: { eq: userId } },
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error("Failed to load user");
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
return (results[0] as User) ?? null;
},
});
if (isLoading) return <Spinner />;
if (isError || !user) return <ErrorBanner message="Could not load user" />;
return (
<div className="mx-detail">
<div className="mx-detail-header">
<a href="/users" className="mx-back-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
Back
</a>
</div>
<div className="mx-detail-body">
<div className="mx-detail-author">
<div className="mx-tweet-avatar">
<span>M</span>
</div>
<span className="mx-tweet-handle">{user.email}</span>
</div>
</div>
</div>
);
}
function App() {
const appEl = document.getElementById("app")!;
const email = appEl.dataset.currentUserEmail ?? "";
const userId = appEl.dataset.currentUserId ?? "";
const tweetId = appEl.dataset.tweetId || null;
const page = appEl.dataset.page ?? "feed";
const profileUserId = appEl.dataset.userId || null;
const onFeedPage = page === "feed" || page === "tweet";
const onUsersPage = page === "users" || page === "user-detail";
function renderMain() {
switch (page) {
case "tweet":
return (
<AuthCtx.Provider value={{ email, userId }}>
<QueryClientProvider client={queryClient}>
<div className="mx-root">
<aside className="mx-sidebar">
<div className="mx-logo">
<span className="mx-logo-icon"></span>
<span className="mx-logo-text">Mixer</span>
</div>
<nav className="mx-nav">
<a className="mx-nav-item mx-nav-active" href="/feed">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg>
Feed
</a>
</nav>
<div className="mx-sidebar-footer">
{email ? (
<>
<span className="mx-version" style={{ color: "var(--mx-fg2)" }}>{email}</span>
<a className="mx-auth-link" href="/auth/sign-out">Sign out</a>
</>
) : (
<>
<a className="mx-auth-link" href="/register">Create account</a>
<a className="mx-auth-link" href="/auth/sign-in">Sign in</a>
</>
)}
<span className="mx-version">v0.1.0</span>
</div>
</aside>
<main className="mx-main">
{tweetId ? (
<>
<header className="mx-header">
<h1 className="mx-header-title">Tweet</h1>
</header>
<TweetDetail tweetId={tweetId} />
<TweetDetail tweetId={tweetId!} />
</>
) : (
);
case "users":
return (
<>
<header className="mx-header">
<h1 className="mx-header-title">Users</h1>
</header>
<UserList />
</>
);
case "user-detail":
return (
<>
<header className="mx-header">
<h1 className="mx-header-title">Profile</h1>
</header>
<UserDetail userId={profileUserId!} />
</>
);
default:
return (
<>
<header className="mx-header">
<h1 className="mx-header-title">Feed</h1>
@@ -841,7 +1068,51 @@ function App() {
<Feed />
</>
);
}
}
return (
<AuthCtx.Provider value={{ email, userId }}>
<QueryClientProvider client={queryClient}>
<div className="mx-root">
<aside className="mx-sidebar">
<div className="mx-logo">
<span className="mx-logo-icon"></span>
<span className="mx-logo-text">Mixer</span>
</div>
<nav className="mx-nav">
<a className={`mx-nav-item${onFeedPage ? " mx-nav-active" : ""}`} href="/feed">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg>
Feed
</a>
<a className={`mx-nav-item${onUsersPage ? " mx-nav-active" : ""}`} href="/users">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
</svg>
Users
</a>
</nav>
<div className="mx-sidebar-footer">
{email ? (
<>
<span className="mx-version" style={{ color: "var(--mx-fg2)" }}>{email}</span>
<a className="mx-auth-link" href="/auth/sign-out">Sign out</a>
</>
) : (
<>
<a className="mx-auth-link" href="/register">Create account</a>
<a className="mx-auth-link" href="/auth/sign-in">Sign in</a>
</>
)}
<span className="mx-version">v0.1.0</span>
</div>
</aside>
<main className="mx-main">
{renderMain()}
</main>
<div className="mx-rightbar">

View File

@@ -1,5 +1,5 @@
defmodule Mixer.Accounts do
use Ash.Domain, otp_app: :mixer, extensions: [AshAdmin.Domain]
use Ash.Domain, otp_app: :mixer, extensions: [AshTypescript.Rpc, AshAdmin.Domain]
admin do
show? true
@@ -10,4 +10,10 @@ defmodule Mixer.Accounts do
resource Mixer.Accounts.User
resource Mixer.Accounts.ApiKey
end
typescript_rpc do
resource Mixer.Accounts.User do
rpc_action :read_user, :read
end
end
end

View File

@@ -4,7 +4,7 @@ defmodule Mixer.Accounts.User do
domain: Mixer.Accounts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshAuthentication]
extensions: [AshAuthentication, AshTypescript.Resource]
authentication do
add_ons do
@@ -66,6 +66,10 @@ defmodule Mixer.Accounts.User do
repo Mixer.Repo
end
typescript do
type_name "users"
end
actions do
defaults [:read]
@@ -282,6 +286,10 @@ defmodule Mixer.Accounts.User do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
policy action_type(:read) do
authorize_if always()
end
end
attributes do

View File

@@ -6,14 +6,22 @@ defmodule MixerWeb.PageController do
end
def index(conn, _params) do
render_spa(conn, nil)
render_spa(conn, %{page: "feed", tweet_id: nil, user_id: nil})
end
def show(conn, %{"tweet_id" => tweet_id}) do
render_spa(conn, tweet_id)
render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil})
end
defp render_spa(conn, tweet_id) do
def users_index(conn, _params) do
render_spa(conn, %{page: "users", tweet_id: nil, user_id: nil})
end
def user_show(conn, %{"user_id" => user_id}) do
render_spa(conn, %{page: "user-detail", tweet_id: nil, user_id: user_id})
end
defp render_spa(conn, %{page: page, tweet_id: tweet_id, user_id: user_id}) do
asset_host = Application.get_env(:waffle, :asset_host, "http://localhost:3900")
bucket = Application.get_env(:waffle, :bucket, "mixer-bucket")
@@ -22,7 +30,9 @@ defmodule MixerWeb.PageController do
|> render(:index,
current_user: conn.assigns[:current_user],
media_host: "#{asset_host}/#{bucket}",
tweet_id: tweet_id
page: page,
tweet_id: tweet_id,
user_id: user_id
)
end
end

View File

@@ -2,5 +2,7 @@
data-current-user-id={if @current_user, do: @current_user.id, else: ""}
data-current-user-email={if @current_user, do: @current_user.email, else: ""}
data-asset-host={@media_host}
data-tweet-id={@tweet_id || ""}>
data-page={@page}
data-tweet-id={@tweet_id || ""}
data-user-id={@user_id || ""}>
</div>

View File

@@ -40,6 +40,8 @@ defmodule MixerWeb.Router do
get "/", PageController, :home
get "/feed", PageController, :index
get "/feed/:tweet_id", PageController, :show
get "/users", PageController, :users_index
get "/users/:user_id", PageController, :user_show
post "/rpc/run", AshTypescriptRpcController, :run
post "/rpc/validate", AshTypescriptRpcController, :validate
post "/upload", UploadController, :create

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

View File

@@ -0,0 +1,5 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

BIN
priv/static/robots.txt.gz Normal file

Binary file not shown.