initial version v0.1

This commit is contained in:
2025-07-11 19:41:14 -07:00
commit 29d1f0e9ac
43 changed files with 6245 additions and 0 deletions

63
README.md Normal file
View File

@@ -0,0 +1,63 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
});
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x';
import reactDom from 'eslint-plugin-react-dom';
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
});
```
## Internationalization of the app (I18N)
This template supports internationalization (i18n) using [i18next](https://www.i18next.com/), allowing seamless translation of UI text into multiple languages.
The setup includes modularized translation files (namespaces), language detection, context and runtime language switching.
Files with translation are in `src/i18n/locales/<locale>` folder.
`core` namespace is already present and active.

37
eslint.config.js Normal file
View File

@@ -0,0 +1,37 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import prettierPlugin from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended, prettier],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
prettier: prettierPlugin,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'prettier/prettier': 'error',
},
},
{
// This disables ESLint rules that would conflict with Prettier
name: 'prettier-config',
rules: prettierConfig.rules,
}
);

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qortal Q-App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4617
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "q-follow",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"format:check": "prettier --check .",
"format": "prettier --write .",
"initialize": "node scripts/initialize.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.0.1",
"@mui/material": "^7.0.1",
"i18next": "^25.1.2",
"jotai": "^2.12.4",
"qapp-core": "^1.0.36",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^15.5.1",
"react-router-dom": "^7.3.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"prettier": "^3.5.3",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.3.5"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

30
scripts/initialize.js Normal file
View File

@@ -0,0 +1,30 @@
import { writeFile, access } from 'fs/promises';
import { constants } from 'fs';
import { randomBytes } from 'crypto';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// Resolve __dirname in ES Modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Define the file path (adjusted for new location in scripts/)
const filePath = join(__dirname, '..', 'src', 'qapp-config.ts');
try {
// Check if file already exists
await access(filePath, constants.F_OK);
console.log('⚠️ qapp-config.ts already exists. Skipping creation.');
} catch {
// File does not exist, proceed to create it
const publicSalt = randomBytes(32).toString('base64');
const tsContent = `export const publicSalt = "${publicSalt}";\n`;
try {
await writeFile(filePath, tsContent, 'utf8');
console.log('✅ qapp-config.ts has been created with a unique public salt.');
} catch (error) {
console.error('❌ Error writing qapp-config.ts:', error);
}
}

28
src/App.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { Typography } from "@mui/material";
import { useAtom } from "jotai";
import { useGlobal } from "qapp-core";
import { useTranslation } from "react-i18next";
import { EnumTheme, themeAtom } from "./state/global/system";
function App() {
const { auth } = useGlobal();
const { t } = useTranslation(["core"]);
// retrieve the theme 'light' or 'dark'
const [theme] = useAtom(themeAtom);
return (
<>
<Typography>
{t("core:welcome", { postProcess: "capitalizeAll" })} {auth?.name}
</Typography>
<Typography>
{t("core:using_theme", { postProcess: "capitalizeFirstChar" })}{" "}
{theme === EnumTheme.DARK ? "Dark" : "Light"}
</Typography>
</>
);
}
export default App;

23
src/AppWrapper.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { GlobalProvider } from "qapp-core";
import Layout from "./styles/Layout";
import { publicSalt } from "./qapp-config";
export const AppWrapper = () => {
return (
<GlobalProvider
config={{
appName: "Q-Follow",
auth: {
balanceSetting: {
interval: 180000,
onlyOnMount: false,
},
authenticateOnMount: true,
},
publicSalt: publicSalt,
}}
>
<Layout />
</GlobalProvider>
);
};

View File

@@ -0,0 +1,35 @@
import React, { useState } from "react";
import { addListItem } from "../services/qortalAPI";
const BlockAddressInput: React.FC = () => {
const [address, setAddress] = useState("");
const handleBlock = async () => {
if (!address) return;
try {
await addListItem("blockedAddresses", address);
alert(`Address ${address} blocked successfully!`);
setAddress("");
} catch (error) {
console.error("Failed to block address:", error);
}
};
return (
<div className="block-input-container">
<h4>Block an ADDRESS Directly:</h4>
<input
type="text"
placeholder="Paste address here"
value={address}
onChange={(e) => setAddress(e.target.value)}
className="block-input"
/>
<button onClick={handleBlock} className="block-button">
Block Address
</button>
</div>
);
};
export default BlockAddressInput;

View File

@@ -0,0 +1,61 @@
import React, { useState } from "react";
import { searchNames } from "../services/qortalAPI";
import UserActions from "./UserActions";
interface SearchBarProps {
followedNames: string[];
blockedNames: string[];
refreshLists: () => void;
}
const SearchBar: React.FC<SearchBarProps> = ({
followedNames,
blockedNames,
refreshLists,
}) => {
const [query, setQuery] = useState("");
const [results, setResults] = useState<any[]>([]);
const handleSearch = async (value: string) => {
setQuery(value);
if (value.length < 2) {
setResults([]);
return;
}
try {
const names = await searchNames(value);
setResults(names);
} catch (error) {
console.error("Search failed:", error);
setResults([]);
}
};
return (
<div className="search-input-container">
<h4>Search for a NAME to Follow/Block:</h4>
<input
type="text"
placeholder="Search names..."
value={query}
onChange={(e) => handleSearch(e.target.value)}
className="search-input"
/>
<ul className="universal-name-list">
{results.map((result) => (
<li key={result.name}>
{result.name}{" "}
<UserActions
name={result.name}
followedNames={followedNames}
blockedNames={blockedNames}
refreshLists={refreshLists}
/>
</li>
))}
</ul>
</div>
);
};
export default SearchBar;

View File

@@ -0,0 +1,101 @@
import React, { useState } from "react";
import { addListItem, removeListItem } from "../services/qortalAPI";
interface Props {
name: string;
followedNames: string[];
blockedNames: string[];
refreshLists: () => void;
}
const UserActions: React.FC<Props> = ({
name,
followedNames,
blockedNames,
refreshLists,
}) => {
const [showConfirm, setShowConfirm] = useState(false);
const isFollowed = followedNames.includes(name);
const isBlocked = blockedNames.includes(name);
const follow = async () => {
await addListItem("followedNames", name);
refreshLists();
};
const unfollow = async () => {
await removeListItem("followedNames", name);
refreshLists();
};
const unblock = async () => {
await removeListItem("blockedNames", name);
refreshLists();
};
const blockName = () => setShowConfirm(true);
const confirmBlock = async (blockBoth: boolean) => {
try {
await addListItem("blockedNames", name);
if (blockBoth) {
const nameData = await qortalRequest({
action: "GET_NAME_DATA",
name,
});
if (nameData?.owner) {
await addListItem("blockedAddresses", nameData.owner);
}
}
refreshLists();
setShowConfirm(false);
} catch (error) {
console.error("Failed to block:", error);
}
};
return (
<>
{/* Priority: Blocked > Followed > Neutral */}
{isBlocked ? (
<button className="remove-btn" onClick={unblock}>
Unblock
</button>
) : isFollowed ? (
<button className="remove-btn" onClick={unfollow}>
Unfollow
</button>
) : (
<>
<button className="follow-btn" onClick={follow}>
Follow
</button>
<button className="block-btn" onClick={blockName}>
Block Name
</button>
</>
)}
{showConfirm && (
<div className="block-confirm">
<p>
Blocking a name blocks data published by that name (QDN, shops,
etc.).
<br />
Blocking the address blocks ALL transactions from that address
(trades, chats, etc.).
</p>
<button className="remove-btn" onClick={() => confirmBlock(false)}>
Block Only Name
</button>
<button className="remove-btn" onClick={() => confirmBlock(true)}>
Block Name + Address
</button>
<button onClick={() => setShowConfirm(false)}>Cancel</button>
</div>
)}
</>
);
};
export default UserActions;

View File

@@ -0,0 +1,85 @@
import React from "react";
import { removeListItem } from "../services/qortalAPI";
import { useTheme } from "@mui/material";
interface UserListProps {
followedNames: string[];
blockedNames: string[];
blockedAddresses: string[];
refreshLists: () => void;
}
const UserList: React.FC<UserListProps> = ({
followedNames,
blockedNames,
blockedAddresses,
refreshLists,
}) => {
const theme = useTheme();
React.useEffect(() => {
document.body.dataset.theme = theme.palette.mode;
}, [theme.palette.mode]);
const remove = async (listName: string, item: string) => {
await removeListItem(listName as any, item);
refreshLists();
};
return (
<div className="list-grid">
<div className="list-section followed-section">
<h3>Followed Names</h3>
<ul className="universal-name-list">
{followedNames.map((name) => (
<li key={name}>
{name}{" "}
<button
className="remove-btn"
onClick={() => remove("followedNames", name)}
>
Unfollow
</button>
</li>
))}
</ul>
</div>
<div className="list-section blocked-names-section">
<h3>Blocked Names</h3>
<ul className="universal-name-list">
{blockedNames.map((name) => (
<li key={name}>
{name}{" "}
<button
className="remove-btn"
onClick={() => remove("blockedNames", name)}
>
Unblock
</button>
</li>
))}
</ul>
</div>
<div className="list-section blocked-addresses-section">
<h3>Blocked Addresses</h3>
<ul className="universal-name-list">
{blockedAddresses.map((address) => (
<li key={address}>
{address}{" "}
<button
className="remove-btn"
onClick={() => remove("blockedAddresses", address)}
>
Unblock
</button>
</li>
))}
</ul>
</div>
</div>
);
};
export default UserList;

View File

@@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { To, useNavigate } from 'react-router-dom';
import { EnumTheme, themeAtom } from '../state/global/system';
import { useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../i18n/i18n';
type Language = 'de' | 'en' | 'es' | 'fr' | 'it' | 'ja' | 'ru' | 'zh';
type Theme = 'dark' | 'light';
interface CustomWindow extends Window {
_qdnTheme: Theme;
_qdnLang: Language;
}
const customWindow = window as unknown as CustomWindow;
export const useIframe = () => {
const setTheme = useSetAtom(themeAtom);
const { i18n } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
const themeColorDefault = customWindow?._qdnTheme;
if (themeColorDefault === 'dark') {
setTheme(EnumTheme.DARK);
} else if (themeColorDefault === 'light') {
setTheme(EnumTheme.LIGHT);
}
const languageDefault = customWindow?._qdnLang;
if (supportedLanguages?.includes(languageDefault)) {
i18n.changeLanguage(languageDefault);
}
function handleNavigation(event: {
data: {
action: string;
path: To;
theme: Theme;
language: Language;
};
}) {
if (event.data?.action === 'NAVIGATE_TO_PATH' && event.data.path) {
navigate(event.data.path); // Navigate directly to the specified path
// Send a response back to the parent window after navigation is handled
window.parent.postMessage(
{ action: 'NAVIGATION_SUCCESS', path: event.data.path },
'*'
);
} else if (event.data?.action === 'THEME_CHANGED' && event.data.theme) {
const themeColor = event.data.theme;
if (themeColor === 'dark') {
setTheme(EnumTheme.DARK);
} else if (themeColor === 'light') {
setTheme(EnumTheme.LIGHT);
}
} else if (
event.data?.action === 'LANGUAGE_CHANGED' &&
event.data.language
) {
if (!supportedLanguages?.includes(event.data.language)) return;
i18n.changeLanguage(event.data.language);
}
}
window.addEventListener('message', handleNavigation);
return () => {
window.removeEventListener('message', handleNavigation);
};
}, [navigate, setTheme]);
return { navigate };
};

56
src/i18n/i18n.ts Normal file
View File

@@ -0,0 +1,56 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import {
capitalizeAll,
capitalizeFirstChar,
capitalizeFirstWord,
} from './processors';
// Load all locale JSON files
const modules = import.meta.glob('./locales/**/*.json', {
eager: true,
}) as Record<string, any>;
// Dynamically detect unique language codes
export const supportedLanguages: string[] = Array.from(
new Set(
Object.keys(modules)
.map((path) => {
const match = path.match(/\.\/locales\/([^/]+)\//);
return match ? match[1] : null;
})
.filter((lang): lang is string => typeof lang === 'string')
)
);
// Construct i18n resources object
const resources: Record<string, Record<string, any>> = {};
for (const path in modules) {
// Path format: './locales/en/core.json'
const match = path.match(/\.\/locales\/([^/]+)\/([^/]+)\.json$/);
if (!match) continue;
const [, lang, ns] = match;
resources[lang] = resources[lang] || {};
resources[lang][ns] = modules[path].default;
}
i18n
.use(initReactI18next)
.use(capitalizeAll as any)
.use(capitalizeFirstChar as any)
.use(capitalizeFirstWord as any)
.init({
resources,
fallbackLng: 'en',
lng: navigator.language,
supportedLngs: supportedLanguages,
ns: ['core'],
defaultNS: 'core',
interpolation: { escapeValue: false },
react: { useSuspense: false },
debug: import.meta.env.MODE === 'development',
});
export default i18n;

View File

@@ -0,0 +1,4 @@
{
"using_theme": "this application is using the theme:",
"welcome": "welcome to Qortal"
}

54
src/i18n/processors.ts Normal file
View File

@@ -0,0 +1,54 @@
export const capitalizeAll = {
type: 'postProcessor',
name: 'capitalizeAll',
process: (value: string) => value.toUpperCase(),
};
export const capitalizeEachFirstChar = {
type: 'postProcessor',
name: 'capitalizeEachFirstChar',
process: (value: string) => {
if (!value?.trim()) return value;
const leadingSpaces = value.match(/^\s*/)?.[0] || '';
const trailingSpaces = value.match(/\s*$/)?.[0] || '';
const core = value
.trim()
.split(/\s+/)
.map(
(word) =>
word.charAt(0).toLocaleUpperCase() + word.slice(1).toLocaleLowerCase()
)
.join(' ');
return leadingSpaces + core + trailingSpaces;
},
};
export const capitalizeFirstChar = {
type: 'postProcessor',
name: 'capitalizeFirstChar',
process: (value: string) => value.charAt(0).toUpperCase() + value.slice(1),
};
export const capitalizeFirstWord = {
type: 'postProcessor',
name: 'capitalizeFirstWord',
process: (value: string) => {
if (!value?.trim()) return value;
const trimmed = value.trimStart();
const firstSpaceIndex = trimmed.indexOf(' ');
if (firstSpaceIndex === -1) {
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
}
const firstWord = trimmed.slice(0, firstSpaceIndex);
const restOfString = trimmed.slice(firstSpaceIndex);
const trailingSpaces = value.slice(trimmed.length);
return firstWord.toUpperCase() + restOfString + trailingSpaces;
},
};

395
src/index.css Normal file
View File

@@ -0,0 +1,395 @@
@font-face {
font-family: 'Inter';
src: url('./styles/fonts/Inter-SemiBold.ttf') format('truetype');
font-weight: 600;
}
@font-face {
font-family: 'Inter';
src: url('./styles/fonts/Inter-ExtraBold.ttf') format('truetype');
font-weight: 800;
}
@font-face {
font-family: 'Inter';
src: url('./styles/fonts/Inter-Bold.ttf') format('truetype');
font-weight: 700;
}
@font-face {
font-family: 'Inter';
src: url('./styles/fonts/Inter-Regular.ttf') format('truetype');
font-weight: 400;
}
:root {
line-height: 1.2;
padding: 0px;
margin: 0px;
box-sizing: border-box;
font-family: 'Inter';
}
.list-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.list-section {
padding: 1rem;
border: 1px solid #ccc;
border-radius: 8px;
background: var(--background-color, #1c1c1c);
}
.list-section h3 {
margin-bottom: 0.5rem;
}
.list-section ul {
list-style: none;
padding: 0;
margin: 0;
}
.list-section li {
margin-bottom: 0.5rem;
}
.list-section button {
margin-left: 0.5rem;
}
.block-confirm {
padding: 1rem;
margin-top: 0.5rem;
border: 1px solid #f39c12;
border-radius: 6px;
background: #2c2c2c;
}
.block-confirm p {
margin-bottom: 0.5rem;
}
.block-confirm button {
margin-right: 0.5rem;
}
@font-face {
font-family: 'Orbitron';
src: url('/fonts/Orbitron-VariableFont_wght.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
.follow-input,
.block-input {
font-family: 'Orbitron', sans-serif;
padding: 1rem;
font-size: 1.2rem;
width: 80%;
border: 2px solid;
border-radius: 8px;
outline: none;
margin-bottom: 0.5rem;
}
.follow-input {
border-color: #00c853;
/* Green */
}
.block-input {
border-color: #d50000;
/* Red */
}
.follow-button,
.block-button {
font-family: 'Orbitron', sans-serif;
padding: 0.75rem 1.5rem;
font-size: 1.1rem;
border: none;
border-radius: 6px;
cursor: pointer;
}
button.follow-btn {
border-color: #00c853;
color: #00c853;
}
button.follow-btn:hover {
background-color: #00c853;
color: #fff;
}
button.block-btn {
border-color: #d50000;
color: #d50000;
}
button.block-btn:hover {
background-color: #d50000;
color: #fff;
}
.search-input-container {
text-align: center;
margin: 1rem 0;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search-input::placeholder {
color: #00c853;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Universal List Styling for ALL Names */
.universal-name-list {
list-style: none;
padding: 0;
margin: 1rem 0;
text-align: center;
}
.universal-name-list li {
font-family: 'Orbitron', sans-serif;
font-size: 1.1rem;
margin-bottom: 0.75rem;
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
animation: fadeIn 0.4s ease both;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.universal-name-list li:hover {
transform: scale(1.03);
box-shadow: 0 0 12px rgba(0, 200, 83, 0.4);
}
.universal-name-list li button {
padding: 0.4rem 0.75rem;
border-radius: 6px;
}
.search-input-container,
.block-input-container {
text-align: center;
margin: 1rem 0;
}
.search-input,
.block-input {
font-family: 'Orbitron', sans-serif;
padding: 1rem;
font-size: 1.2rem;
width: 80%;
border: 2px solid;
border-radius: 8px;
outline: none;
margin-bottom: 0.5rem;
background: var(--input-bg, #ffffff);
color: var(--input-color, #000000);
}
.search-input {
border-color: #00c853;
}
.block-input {
border-color: #d50000;
}
button {
font-family: 'Orbitron', sans-serif;
padding: 0.5rem 1rem;
border: 2px solid transparent;
border-radius: 6px;
background: none;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
button.follow-btn {
border-color: #00c853;
color: #00c853;
}
button.follow-btn:hover {
background-color: #00c853;
color: #fff;
}
button.block-btn {
border-color: #d50000;
color: #d50000;
}
button.block-btn:hover {
background-color: #d50000;
color: #fff;
}
.block-button {
font-family: 'Orbitron', sans-serif;
border: 2px solid #d50000;
color: #d50000;
background: none;
border-radius: 6px;
padding: 0.75rem 1.5rem;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.block-button:hover {
background-color: #d50000;
color: #fff;
}
.input-panel-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
margin-bottom: 2rem;
}
.list-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.list-section {
padding: 1rem;
border: 1px solid #ccc;
border-radius: 8px;
background: var(--section-bg, #f5f5f5);
}
/* Light/Dark Theme Adaptive */
/* Welcome Header */
.welcome-header {
font-family: 'Orbitron', sans-serif;
font-size: 2rem;
text-align: center;
margin: 1.5rem 0;
color: #04aee7;
}
/* Section Headers */
.list-section h3 {
font-family: 'Orbitron', sans-serif;
font-size: 1.4rem;
text-align: center;
color: #888;
/* Dark grey */
margin-bottom: 0.75rem;
}
.block-input-container h4{
font-family: 'Orbitron', sans-serif;
font-size: 1.4rem;
text-align: center;
color: #888;
/* Dark grey */
margin-bottom: 0.75rem;
}
.search-input-container h4 {
font-family: 'Orbitron', sans-serif;
font-size: 1.4rem;
text-align: center;
color: #888;
/* Dark grey */
margin-bottom: 0.75rem;
}
:root {
--input-bg: #ffffff;
--input-color: #000000;
--section-bg: #04080e;
}
/* Section Backgrounds */
.followed-section {
background: var(--section-bg);
color: white;
}
.blocked-names-section {
background: #271608;
color: white;
}
.blocked-addresses-section {
background: #110404;
color: white;
}
/* List Item Buttons (Add/Remove) */
button.add-btn {
font-family: 'Orbitron', sans-serif;
border: 2px solid #00c853;
color: #00c853;
background: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
button.add-btn:hover {
background-color: #00c853;
color: #fff;
}
button.remove-btn {
font-family: 'Orbitron', sans-serif;
border: 2px solid #d50000;
color: #d50000;
background: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
button.remove-btn:hover {
background-color: #d50000;
color: #fff;
}

14
src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import ThemeProviderWrapper from "./styles/theme/theme-provider.tsx";
import "./index.css";
import "./i18n/i18n.ts";
import { Routes } from "./routes/Routes.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProviderWrapper>
<Routes />
</ThemeProviderWrapper>
</StrictMode>
);

49
src/pages/FollowBlock.tsx Normal file
View File

@@ -0,0 +1,49 @@
import React, { useEffect, useState } from "react";
import SearchBar from "../components/SearchBar";
import UserList from "../components/UserList";
import BlockAddressInput from "../components/BlockAddressInput";
import { getListItems } from "../services/qortalAPI";
const FollowBlock: React.FC = () => {
const [followed, setFollowed] = useState<string[]>([]);
const [blockedNames, setBlockedNames] = useState<string[]>([]);
const [blockedAddresses, setBlockedAddresses] = useState<string[]>([]);
const loadLists = async () => {
const followed = await getListItems("followedNames");
const blockedNames = await getListItems("blockedNames");
const blockedAddresses = await getListItems("blockedAddresses");
// Sort alphabetically
setFollowed([...followed].sort((a, b) => a.localeCompare(b)));
setBlockedNames([...blockedNames].sort((a, b) => a.localeCompare(b)));
setBlockedAddresses(
[...blockedAddresses].sort((a, b) => a.localeCompare(b))
);
};
useEffect(() => {
loadLists();
}, []);
return (
<>
<div className="input-panel-wrapper">
<SearchBar
followedNames={followed}
blockedNames={blockedNames}
refreshLists={loadLists}
/>
<BlockAddressInput />
</div>
<UserList
followedNames={followed}
blockedNames={blockedNames}
blockedAddresses={blockedAddresses}
refreshLists={loadLists}
/>
</>
);
};
export default FollowBlock;

1
src/qapp-config.ts Normal file
View File

@@ -0,0 +1 @@
export const publicSalt = "5MZU9rlee/S86/OQ/bzHmLIvdS9zOu/1U7QSatUy/7w=";

36
src/routes/Routes.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { createBrowserRouter, RouterProvider } from "react-router-dom";
// import App from "../App";
import { AppWrapper } from "../AppWrapper";
import FollowBlock from "../pages/FollowBlock";
interface CustomWindow extends Window {
_qdnBase: string;
}
const customWindow = window as unknown as CustomWindow;
const baseUrl = customWindow?._qdnBase || "";
export function Routes() {
const router = createBrowserRouter(
[
{
path: "/",
element: <AppWrapper />,
children: [
{
index: true,
element: <FollowBlock />,
},
{
path: "follow-block",
element: <FollowBlock />, // New page route
},
],
},
],
{
basename: baseUrl,
}
);
return <RouterProvider router={router} />;
}

39
src/services/qortalAPI.ts Normal file
View File

@@ -0,0 +1,39 @@
export async function searchNames(query: string, limit = 20) {
return await qortalRequest({
action: "SEARCH_NAMES",
query,
limit,
offset: 0,
reverse: false,
prefix: true,
});
}
export async function getListItems(listName: "blockedNames" | "blockedAddresses" | "followedNames") {
return await qortalRequest({
action: "GET_LIST_ITEMS",
list_name: listName,
});
}
export async function addListItem(
listName: "blockedNames" | "blockedAddresses" | "followedNames",
name: string,
) {
return await qortalRequest({
action: "ADD_LIST_ITEMS",
list_name: listName,
items: [name],
});
}
export async function removeListItem(
listName: "blockedNames" | "blockedAddresses" | "followedNames",
name: string,
) {
return await qortalRequest({
action: "DELETE_LIST_ITEM",
list_name: listName,
items: [name],
});
}

View File

@@ -0,0 +1,9 @@
import { atom } from 'jotai';
export enum EnumTheme {
LIGHT = 1,
DARK = 2,
}
// Atom to hold the current theme
export const themeAtom = atom<EnumTheme>(EnumTheme.DARK);

26
src/styles/Layout.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { Outlet } from "react-router-dom";
import { useIframe } from "../hooks/useIframeListener";
import { useGlobal } from "qapp-core";
const Layout = () => {
const { auth } = useGlobal();
useIframe();
return (
<div className="app-layout">
{/* Your header/sidebar/etc */}
<header>
<h1 className="welcome-header">
Welcome to Q-Follow,{" "}
<span style={{ color: "red" }}>{auth?.name}</span>
</h1>
</header>
{/* Main content */}
<main>
<Outlet /> {/* ← Nested routes will render here */}
</main>
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,135 @@
// Full MUI conversion for theme //todo
import { styled } from '@mui/material/styles';
import { Box, Button, Typography } from '@mui/material';
// Universal List Grid Wrapper
export const ListGrid = styled(Box)(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: theme.spacing(2),
}));
// Shared Section Wrapper
export const ListSection = styled(Box)(({ theme }) => ({
padding: theme.spacing(2),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.paper,
}));
// Dynamic Section Backgrounds
export const FollowedSection = styled(ListSection)``;
export const BlockedNamesSection = styled(ListSection)(({ theme }) => ({
backgroundColor: theme.palette.mode === 'light' ? '#fef5f0' : '#271608',
}));
export const BlockedAddressesSection = styled(ListSection)(({ theme }) => ({
backgroundColor: theme.palette.mode === 'light' ? '#fdeeee' : '#110404',
}));
// Universal List Items
export const UniversalListItem = styled('li')(({ theme }) => ({
fontFamily: 'Orbitron, sans-serif',
fontSize: '1.1rem',
marginBottom: theme.spacing(1),
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: theme.spacing(1),
flexWrap: 'wrap',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'scale(1.03)',
boxShadow: `0 0 12px ${theme.palette.mode === 'dark' ? 'rgba(0, 200, 83, 0.4)' : 'rgba(63, 81, 181, 0.4)'}`,
},
}));
// Buttons
export const FollowButton = styled(Button)({
borderColor: '#00c853',
color: '#00c853',
backgroundColor: 'transparent',
'&:hover': {
backgroundColor: '#00c853',
color: '#fff',
},
});
export const BlockButton = styled(Button)({
borderColor: '#d50000',
color: '#d50000',
backgroundColor: 'transparent',
'&:hover': {
backgroundColor: '#d50000',
color: '#fff',
},
});
export const AddButton = styled(Button)({
borderColor: '#00c853',
color: '#00c853',
backgroundColor: 'transparent',
'&:hover': {
backgroundColor: '#00c853',
color: '#fff',
},
});
export const RemoveButton = styled(Button)({
borderColor: '#d50000',
color: '#d50000',
backgroundColor: 'transparent',
'&:hover': {
backgroundColor: '#d50000',
color: '#fff',
},
});
// Typography Components
export const WelcomeHeader = styled(Typography)(({ theme }) => ({
fontFamily: 'Orbitron, sans-serif',
fontSize: '2rem',
textAlign: 'center',
margin: theme.spacing(3, 0),
color: '#00c853',
}));
export const SectionHeader = styled(Typography)(({ theme }) => ({
fontFamily: 'Orbitron, sans-serif',
fontSize: '1.4rem',
textAlign: 'center',
color: '#888',
marginBottom: theme.spacing(1),
}));
// Input Styles (search, block inputs)
export const InputField = styled('input')(({ theme, }) => ({
fontFamily: 'Orbitron, sans-serif',
padding: theme.spacing(1),
fontSize: '1.2rem',
width: '80%',
border: `2px solidrgb(15, 73, 34)`,
borderRadius: theme.shape.borderRadius,
outline: 'none',
marginBottom: theme.spacing(1),
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
}));
// Input Container Wrapper
export const InputPanelWrapper = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: theme.spacing(2),
marginBottom: theme.spacing(4),
}));
// Block Confirm Wrapper
export const BlockConfirm = styled(Box)(({ theme }) => ({
padding: theme.spacing(2),
marginTop: theme.spacing(1),
border: `1px solid #f39c12`,
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.mode === 'dark' ? '#2c2c2c' : '#fafafa',
}));

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,23 @@
import React, { FC } from 'react';
import { ThemeProvider } from '@emotion/react';
import { lightTheme, darkTheme } from './theme';
import { CssBaseline } from '@mui/material';
import { EnumTheme, themeAtom } from '../../state/global/system';
import { useAtom } from 'jotai';
interface ThemeProviderWrapperProps {
children: React.ReactNode;
}
const ThemeProviderWrapper: FC<ThemeProviderWrapperProps> = ({ children }) => {
const [theme] = useAtom(themeAtom);
return (
<ThemeProvider theme={theme === EnumTheme.LIGHT ? lightTheme : darkTheme}>
<CssBaseline />
{children}
</ThemeProvider>
);
};
export default ThemeProviderWrapper;

120
src/styles/theme/theme.ts Normal file
View File

@@ -0,0 +1,120 @@
import { createTheme } from '@mui/material/styles';
const commonThemeOptions = {
typography: {
fontFamily: ['Inter'].join(','),
h1: {
fontSize: '2rem',
fontWeight: 600,
},
h2: {
fontSize: '1.75rem',
fontWeight: 500,
},
h3: {
fontSize: '1.5rem',
fontWeight: 500,
},
h4: {
fontSize: '1.25rem',
fontWeight: 500,
},
h5: {
fontSize: '1rem',
fontWeight: 500,
},
h6: {
fontSize: '0.875rem',
fontWeight: 500,
},
body1: {
fontSize: '1rem',
fontWeight: 400,
lineHeight: 1.5,
letterSpacing: '0.5px',
},
body2: {
fontSize: '0.875rem',
fontWeight: 400,
lineHeight: 1.4,
letterSpacing: '0.2px',
},
},
spacing: 8,
shape: {
borderRadius: 4,
},
breakpoints: {
values: {
xs: 0,
sm: 600,
md: 900,
lg: 1200,
xl: 1536,
},
},
MuiDialog: {
styleOverrides: {
paper: {
backgroundImage: 'none',
},
},
},
MuiPopover: {
styleOverrides: {
paper: {
backgroundImage: 'none',
},
},
},
};
const lightTheme = createTheme({
...commonThemeOptions,
palette: {
mode: 'light',
primary: {
main: 'rgb(63, 81, 181)',
dark: 'rgb(113, 198, 212)',
light: 'rgb(180, 200, 235)',
},
secondary: {
main: 'rgba(194, 222, 236, 1)',
},
background: {
default: 'rgba(250, 250, 250, 1)',
paper: 'rgb(220, 220, 220)', // darker card background
},
text: {
primary: 'rgba(0, 0, 0, 0.87)', // 87% black (slightly softened)
secondary: 'rgba(0, 0, 0, 0.6)', // 60% black
},
},
});
const darkTheme = createTheme({
...commonThemeOptions,
palette: {
mode: 'dark',
primary: {
main: 'rgb(100, 155, 240)',
dark: 'rgb(45, 92, 201)',
light: 'rgb(130, 185, 255)',
},
secondary: {
main: 'rgb(69, 173, 255)',
},
background: {
default: 'rgb(49, 51, 56)',
paper: 'rgb(62, 64, 68)',
},
text: {
primary: 'rgb(255, 255, 255)',
secondary: 'rgb(179, 179, 179)',
},
},
});
export { lightTheme, darkTheme };

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

27
tsconfig.app.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["qapp-core/global"]
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["qapp-core/global"]
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
base: '',
optimizeDeps: {
include: ['@mui/material', '@mui/styled-engine', '@mui/system'],
},
});